Compare commits

..
331 Commits
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 fcda4d3f85 Slurm's jobs in llmgw's accounting: projects as accounts, jobs priced
CI / test (push) Failing after 7m24s
With slurm.users llmgw, each project is a Slurm account, its organization's
and team's above it, with the project's members and its team's and
organization's on it; slurm.conf holds a job to its user's accounts. The
controller's agent keeps them with sacctmgr, only what it made, and reports
the jobs that ended in the last day; the leader records each once, its
GPU-hours (core-hours without cards) priced at auth.gpu_hour_cost into its
project's usage as model slurm/PARTITION.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 11:54:01 +01:00
Ilker ManapandClaude Opus 5.5 8547528fb7 Prices from what the cluster costs: ctl prices suggest
CI / test (push) Successful in 9m51s
auth.gpu_hour_cost (and core_hour_cost) is what an hour of a GPU (a core)
costs the operator. The leader keeps, by the hour, the GPU- and core-hours
each model's replicas hold (held_1h); llmgw ctl prices suggest spreads a
model's over the tokens it served, an output token counted as four input
ones and a cached prompt token as a tenth of one, and --apply writes the
prices into auth.pricing. Idle hours are in the cost, so a model held and
little used comes out dear; one held and billed nothing is shown with what
it held.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-15 06:54:55 +01:00
Ilker ManapandClaude Opus 5.5 b8d3c084c5 BACKLOG P: AD sign-in to Slurm user, tried end to end
CI / test (push) Failing after 7m31s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 23:00:28 +01:00
Ilker ManapandClaude Opus 5.5 57f0fd2ed0 Slurm's jobs in containers: pyxis and enroot, installed by the agents
CI / test (push) Failing after 6m53s
slurm.containers (ctl slurm containers on) has every Slurm machine's agent
install enroot from NVIDIA's release and build pyxis against its own
Slurm, both pinned and checked against their digests, and on GPU machines
nvidia-container-cli from NVIDIA's repository. enroot keeps each user's
containers under /run/enroot and /var/lib/enroot, needing no login
session, with the shared path in every container. The leader has Slurm
load pyxis only once every machine reports it: plugstack.conf's
"required" fails every job on a machine without the plugin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 21:31:20 +01:00
Ilker ManapandClaude Opus 5.5 c3a75dcf3f A node being lent says when a replica with nowhere to go is stopped
CI / test (push) Successful in 9m37s
Tried on the PoC: lending cuda01, the one GPU node, its model had nowhere
else to go and waited out the drain's five-minute grace, while ctl slurm
status said only "1 replica(s) still to move off". It now says the time
that replica is stopped at. Also recorded: Slurm on a GPU node, tried on
cuda01 (gres, ConstrainDevices, accounting of gres/gpu).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:45:57 +01:00
Ilker ManapandClaude Opus 5.5 4b4306bb6a Slurm users reach a node only through a job of theirs
CI / test (push) Successful in 9m41s
llmgw's Slurm users log in to the controller freely and to another Slurm
machine only while a job of theirs runs there: the agent installs
pam_slurm_adopt and puts it in sshd's account stack for the users it made
alone, so root and the machine's own users are never locked out. Their
session is put in the job's extern step (PrologFlags=Contain), and
pam_systemd, which would move it out, is skipped for them by taking the
session file into sshd's stack with a guard in front of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 20:30:49 +01:00
Ilker ManapandClaude Opus 5.5 a00c92648e Status says when the cluster's alerts go nowhere
CI / test (push) Failing after 6m57s
Setup asks for webhooks, but one left empty was never mentioned again: a
lost node or a judge down was told nobody. With no notify.webhooks in the
cluster's settings, ctl status and the console's Overview say so, and how
to set one. BACKLOG: the judge's refusal after a placement change, tried
again on idle VMs, did not happen; a cold judge's first answer is 2-3x
slower but inside its budget.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 22:49:37 +01:00
Ilker ManapandClaude Opus 5.5 2c32a58a06 Suspending a person says their keys are refused, which they are
CI / test (push) Successful in 9m41s
The message said their API keys still worked and should be revoked; the
store has refused a key whose owner is not active for a while. Tried on
the test cluster: refused 26 s after the suspend (auth.cache_ttl, 30 s),
answered again 6 s after reinstating.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 22:38:54 +01:00
Ilker ManapandClaude Opus 5.5 d8ce573bdb Slurm's shared files and users: one path everywhere, accounts as Linux users
CI / test (push) Failing after 7m1s
slurm.shared mounts the site's NFS export, has an llmgw node export the path
over NFS to the Slurm machines alone (2049 in the firewall), or looks for a
file system the site mounts. slurm.users "llmgw" makes each active account a
Linux user on every Slurm machine: login from its e-mail, a UID from 200000
kept in slurm_users and never reused, its home on the shared path, its SSH
keys from the portal's Profile (or ctl slurm users keys) read by sshd from
/etc/llmgw/ssh-keys. server init --slurm chooses /shared from the first
machine and llmgw's users; --slurm-shared and --slurm-users change that.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 21:34:34 +01:00
Ilker ManapandClaude Fable 5.1 b9d20e7bba Licence: FSL-1.1 with an Apache 2.0 future licence
CI / test (push) Successful in 9m48s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-12 19:55:24 +01:00
Ilker ManapandClaude Opus 5.5 0d78f9bb34 A machine given by address is reached through its ssh config Host
node add (and server add, mirror peer, patroni init) is usually given an
address, while the ssh config names the machine: `Host gpu01` with
`HostName 10.0.0.21` and the User, IdentityFile or ProxyJump it needs. That
block did not match the address, so the address had to be added to its
Host line by hand. Now the concrete Host names in the config and its
Includes are read, ssh -G says each one's HostName, and the first that is
the address (or resolves to it) is connected through.

Under sudo, the caller's name is now a fallback: a User in the config's
block for the machine comes first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:42:23 +01:00
Ilker ManapandClaude Opus 5.5 5ac6bef463 BACKLOG P: the fresh install tried, and what it left open
CI / test (push) Successful in 10m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:42:22 +01:00
Ilker ManapandClaude Opus 5.5 557a5a1ef3 Found on a fresh three-machine install: what the screens said wrong
CI / test (push) Successful in 11m5s
- server init printed every engine's line again on each look while the
  node installed (some 280 lines); each engine's line now once, and again
  only when it changes, as node add does.
- A package install that is no engine's (Slurm's, MariaDB's) read
  ": installing installing mariadb-server, ..."; it is "system
  packages: installing: mariadb-server, ..., with apt-get".
- The slow-judge advice says "ctl model resources JUDGE --cores 4",
  which was refused for want of --memory: cores alone now keep the
  memory as it is.
- server add said both control planes for three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 10:38:18 +01:00
Ilker ManapandClaude Opus 5.5 21828fefce BACKLOG P: slurmdbd done; P6, Slurm's jobs in llmgw's accounting
CI / test (push) Failing after 7m52s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 08:21:40 +01:00
Ilker ManapandClaude Opus 5.5 748f777a56 Slurm's accounting: slurmdbd and a MariaDB on the controller
CI / test (push) Successful in 10m57s
Since 23.11 Slurm runs badly without accounting storage: the main
scheduler passes every job by as InvalidAccount. slurmdbd takes MySQL or
MariaDB only, so the controller's agent installs MariaDB beside it,
answering that machine alone, makes the database and its user (the
password made from the munge key, never on a command line), runs
slurmdbd and adds the cluster, all before slurmctld. A MariaDB that was
on the machine before keeps its settings. slurm.conf accounts the jobs
there, with what each used from its cgroup and the cards, and the
priority is multifactor again. Port 6819 joins Slurm's in the firewall.
ctl slurm status and the console say whether slurmdbd runs; the install
screen, server init --slurm and the guide say what is put where.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-12 07:59:08 +01:00
Ilker ManapandClaude Opus 5.5 02774769a7 Slurm: why priority/basic, said rightly
CI / test (push) Failing after 8m3s
priority/basic did not end InvalidAccount: 23.11 dropped
accounting_storage/none, and without slurmdbd the main scheduler passes
jobs by whatever the priority plugin. Basic stays, fair share needing
slurmdbd anyway.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:21:24 +01:00
Ilker ManapandClaude Opus 5.5 bab0766ee1 Slurm: jobs start on submission, and a lent node stays open across restarts
CI / test (push) Failing after 8m35s
Without accounting storage, 23.11's default priority/multifactor has the
main scheduler pass every job by as InvalidAccount; backfill started it a
pass later. slurm.conf sets priority/basic.

Every node was written State=DRAIN, so each start of slurmctld on new
files drained a lent node again until its agent resumed it. A lent node
is written without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:18:22 +01:00
Ilker ManapandClaude Opus 5.5 5db920bf2c Slurm: a job that asks for no memory gets a share, not the whole node
CI / test (push) Successful in 10m34s
Tried on the PoC: five one-second jobs on an 8-CPU node started 10 s
apart. Each asked for no memory, so each was given the node's all, ran
alone, and the next waited for backfill's pass. Not accounting, as
thought. The partition's DefMemPerCPU is now the leanest node's memory
per CPU.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 23:11:24 +01:00
Ilker ManapandClaude Opus 5.5 356f7e0ced Slurm in the console: each node, and lend or take back from its row
CI / test (push) Successful in 9m39s
The console's Slurm section shows the controller, and each node that runs
slurmd with whose it is now, its state in Slurm and its jobs. Enter on a
row lends the node (its replicas move off first) or takes it back, by the
same change to slurm.lent as ctl slurm lend and reclaim.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:53:26 +01:00
Ilker ManapandClaude Opus 5.5 17287ee8b0 BACKLOG P: failover while lent tried, and fixed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:53:26 +01:00
Ilker ManapandClaude Opus 5.5 0774e901c0 Slurm: a new leader with nothing kept waits for the nodes a while
CI / test (push) Failing after 7m57s
On a cluster upgraded to the kept nodes, the first leader finds none and
wrote slurm.conf without the nodes it had not heard from yet, as before.
A node neither reported nor kept now holds the controller's files back
for slurmsvc.HoldFor (2 minutes) from the leader's first pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:37:52 +01:00
Ilker ManapandClaude Opus 5.5 141a1f46c5 Slurm: a new leader keeps the nodes it has not heard from yet
CI / test (push) Successful in 10m50s
Tried on the PoC: with a job on a lent node, the leader restarted and its
first slurm.conf had no node in it (none had reported to it yet), so
slurmctld dropped the node and the job ended in NODE_FAIL.

The leader keeps slurm.conf's nodes and the controller's address in the
store's cluster state (slurm-nodes); the next one starts from them and
writes the same files until the nodes report again. With the store
unread, the controller gets no files that leave a node out. A node is
told nothing, rather than that it has no part, until its controller's
address is known: no part stops slurmd, and its jobs with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:33:53 +01:00
Ilker ManapandClaude Opus 5.5 3387f97f6a Slurm's ports for the cluster only, and srun in a range of its own
CI / test (push) Successful in 9m40s
slurm.conf sets SrunPortRange=60001-63000, so a site's firewall can let
srun's step connections in. A node given a part in Slurm adds 6817-6818
and that range to llmgw's nftables table, open to the cluster's networks
and closed to everyone else, as the engines' ports are.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 22:10:37 +01:00
Ilker ManapandClaude Opus 5.5 914985bf9f Slurm in the first install: server init --slurm, and slurm.all_nodes
CI / test (push) Successful in 9m30s
The choices screen has a Slurm part, and --slurm writes slurmctld on this
machine's node and slurmd on every node, those that join later too, into
server.yaml. slurm.all_nodes (ctl slurm setup --all-nodes) takes the nodes
from the leader's inventory. No node is lent at install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 14:50:35 +01:00
Ilker ManapandClaude Opus 5.5 b15944aa1f A package install that is no engine's clears its progress; slurmd retried
CI / test (push) Successful in 10m15s
Found on the PoC: InstallPackages reported 'installing munge, slurm-client,
slurmd' under no engine and never cleared it, so the node's page kept
saying so (infiniband-diags and perftest did the same). slurmd started
before the leader has written its node into slurm.conf is refused; the
drop-in now has systemd try it again every 15 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:23:53 +01:00
Ilker ManapandClaude Opus 5.5 dcf4e256e6 BACKLOG P: tried on the PoC
CI / test (push) Failing after 7m26s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:20:32 +01:00
Ilker ManapandClaude Opus 5.5 849760c67d Slurm: no second restart; backfill every 10 s, MailProg set
The InvalidAccount seen on the PoC was not the reconfigure: 23.11.4 without
accounting storage has its main scheduler mark a batch job so, and backfill
starts it on its next pass (job 4 waited 15 s). bf_interval=10 keeps that
short; the extra restart is taken out again. MailProg=/bin/true quiets the
warning on every start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:18:35 +01:00
Ilker ManapandClaude Opus 5.5 087552136f Slurm: slurmctld started again after a reconfigure; no accounting gathered
Found on the PoC: Slurm 23.11.4 without accounting storage left batch jobs
pending InvalidAccount after scontrol reconfigure until slurmctld was
restarted. And jobacct_gather with nowhere to keep it logs an error per job.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:16:31 +01:00
Ilker ManapandClaude Opus 5.5 d2c27e2c76 Slurm: slurmd started as its unit expects, and slurmd's own stepd not a job
Found on the PoC (Ubuntu 24.04, Slurm 23.11.4): the packaged unit is
Type=notify, so slurmd needs --systemd or systemd times it out; and slurmd
keeps a 'slurmstepd infinity' for its cgroup v2 scope, which was counted as
a job and kept the node from llmgw.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:11:41 +01:00
Ilker ManapandClaude Opus 5.5 8a5a04ed77 Slurm: the version from the daemon, not scontrol
Found on the PoC: scontrol looks for its files even for --version, and a
configless node has none before slurmd has fetched them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 13:06:42 +01:00
Ilker ManapandClaude Opus 5.5 410fe545ce Slurm beside llmgw, P3 and P4: the leader hands nodes over; ctl slurm
CI / test (push) Successful in 9m40s
internal/control/slurmsvc tells each node what it is, writes slurm.conf
and gres.conf from the nodes' reports and the inventory, and moves each node
of slurm.nodes between llmgw and Slurm: lent, drained of replicas first, then
resumed in Slurm; taken back, drained in Slurm and cordoned in llmgw until
its last job step ends. Kept every tick from the settings and the agents'
reports, so a new leader carries on. A broken Slurm never keeps a node from
llmgw. GET /api/v1/slurm; ctl slurm status, setup, lend, reclaim, off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:33:14 +01:00
Ilker ManapandClaude Opus 5.5 35de47444c Slurm beside llmgw, P2: the agent keeps its node's part
A Slurm message says whether the node runs slurmctld on the leader's files,
slurmd configless, both or neither, and whether it is lent. The agent
installs munge and Slurm from the machine's sources (22.05 or newer, for
cgroup v2), writes the munge key, starts slurmd through a drop-in with
--conf-server, resumes a lent node that llmgw drained and drains one that is
not, never touching a drain a Slurm administrator made, and reports its
version, slurmd -C, state and jobs in SlurmStatus. Told it has no part, it
stops what it started. New nodes enter slurm.conf drained.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:25:41 +01:00
Ilker ManapandClaude Opus 5.5 d439d507d7 Slurm beside llmgw, P1: its settings and the files a controller runs on
slurm.{enabled,cluster_name,controller,nodes,lent,partition} in the cluster's
settings, checked; internal/slurm writes slurm.conf (configless), gres.conf
and cgroup.conf from the nodes' slurmd -C lines and GPUs, derives the munge
key from a cluster secret, and reads scontrol and version output.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:20:31 +01:00
Ilker ManapandClaude Opus 5.5 89767c8be1 BACKLOG P: training beyond one card, in llmgw or Slurm beside it
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-11 12:12:07 +01:00
Ilker ManapandClaude Opus 5.5 d864438272 A model that failed to start is placed elsewhere next
CI / test (push) Successful in 9m31s
The same inputs place a replica on the same node and card, so an on-demand
model that failed to load there was started there again after every
backoff until it was parked. The reconciler remembers where a model's
replicas failed and places the next away from them when anything else
fits (Scheduler.Away); with nowhere else, there again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 09:22:11 +01:00
Ilker ManapandClaude Opus 5.5 6320dd5b13 A new stream is told the node's files again
CI / test (push) Successful in 9m31s
Registration carries what the agent's startup scan found, and a heartbeat
sends the list only when it changed since last sent; so a file deleted
after the agent started came back on a control plane that restarted. The
fingerprint is now forgotten with the provisioning one on every stream.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 22:14:52 +01:00
Ilker ManapandClaude Opus 5.5 5732a5ebda The serve forms start at the longest context that fits
CI / test (push) Successful in 9m26s
The mirror page's form began every GGUF model at 4096 tokens, the
console's at 4096 or the plan's figure. Both now start at what the model
was trained for, cut to the most whose KV cache fits beside the weights
where the form places it; the mirror page's slots are as many of that, up
to four, as the room left holds. Plan carries the trained context.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:38:41 +01:00
Ilker ManapandClaude Opus 5.5 561a536477 The weight sweep names a set as its fetch did
CI / test (push) Successful in 9m33s
A set is fetched into a directory named from its address without the
trailing slash; Orphans named it from the catalog's address with it, so
every set in the catalog was offered for deletion: 330 GB of six models on
the PoC. One function, cacheSource, now gives both the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:01:27 +01:00
Ilker ManapandClaude Opus 5.5 45fe7daaa0 The admin API document regenerated for CollectResult's left counts
CI / test (push) Successful in 9m32s
cba4897 added fields without regenerating it, and
TestTheAdminAPIDocumentIsNotStale failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:38:08 +01:00
Ilker ManapandClaude Opus 5.5 102ca9556f An organization's admin, and forms only for those who may send them
C1. A membership of an organization may be admin as well as member and
manager. An admin sees /org as a manager does and changes the
organization from it: its teams and projects, who of the approved
accounts is in them, its members' roles, and the keys spent in its
projects. Every id a form names is checked against the organization;
limits, accounts, the organization's name and the cluster stay with the
cluster's staff, and an admin cannot take their own role away.

A group from OIDC, SCIM or a directory no longer rewrites a membership
it names as a plain member at every sign-in, which took a manager's role
away (EnsureMembership).

Pages draw a form only for someone who may send it: Nav.May asks the
staff rule staffMay keeps, and a page whose forms a person may not send
gets no form token. A viewer sees the people, limits and mirror pages
without forms; an operator the mirror's.

Forms are compact: labels close to their fields, fields as wide as what
goes in them, smaller buttons, and the short forms on one line.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:38:06 +01:00
Ilker ManapandClaude Opus 5.5 2cb1afda55 The introduction's comparison on one slide
CI / test (push) Failing after 6m38s
Four slides of products and cards become one table: llmgw beside API
gateways (LiteLLM, Kong AI Gateway, Agent Router) and serving stacks
(llm-d, NVIDIA Dynamo, vLLM production-stack), six rows. The PDF is
18 slides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:06:49 +01:00
Ilker ManapandClaude Opus 5.5 b6478e24b7 An introduction for decision makers, linked from the website
CI / test (push) Failing after 6m43s
docs/llmgw-intro.pdf is a 21-slide presentation: what llmgw adds on
premise, screens from a running cluster, and how it compares with
LiteLLM, Kong AI Gateway, Agent Router, llm-d, NVIDIA Dynamo and vLLM
production-stack from their own documentation. The /about page and the
published site link to it in the repository, so the binary does not
carry it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:36:47 +01:00
Ilker ManapandClaude Opus 5.5 cba4897987 Deleting unused model files says what went, and sets go whole
CI / test (push) Failing after 6m44s
A sweep counted every file the nodes were asked to delete as freed as soon
as it asked, and a node refused a set's directory as not empty, so tens of
gigabytes were reported freed and nothing was. The agent removes a set's
directory whole (a link only as a link), and the control plane waits for
each node to list its files again, saying what it no longer lists and what
it kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 10:29:16 +01:00
Ilker ManapandClaude Opus 5.5 3bb52742a4 v0.16.3: changelog and README
Release / build (push) Successful in 5m11s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 10m10s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:43:40 +01:00
Ilker ManapandClaude Opus 5.5 1a3f385be4 Every setting of a served model from the mirror page, and its replicas per profile
CI / test (push) Successful in 10m51s
The row of a served model leads to its card, which now changes its context
and longest answer, its cards, its replicas on each profile with a profile
on CPUs to add and its speed beside, stops, starts and restarts it, and
removes it with its files or without. Stopped on the row stops the model
rather than setting its scaling to nothing. The console gains Replicas per
profile, and ctl models shows the counts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:40:06 +01:00
Ilker ManapandClaude Opus 5.5 e23732fbbb Replicas counted per profile: seven on cards and four on CPUs under one name
A profile's replicas, set on any of a model's profiles, is how many of its
replicas run there; the scaling becomes the sum. The reconciler starts the
ones short of their profile before stopping the ones over, so a change of
split never runs fewer. ctl model replicas sets them, adding a profile on
CPUs to a GGUF model; the router already fills the higher-priority pool
first, which the CPU profile is put below.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 09:21:50 +01:00
Ilker ManapandClaude Opus 5.5 6403e61f4b How fast a model answers on the CPU, before it is served
CI / test (push) Successful in 9m31s
Each agent measures one core's memory rate beside the whole machine's; the
estimate takes a replica's threads at about one core's rate each, up to the
machine's, calibrated by llama-bench on the PoC. The serve form shows it
beside a card's, warns when slow, and asks for the CPU on purpose when a
reply would take minutes; ctl model plan, the console and serve jobs say
it too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:52:15 +01:00
Ilker ManapandClaude Opus 5.5 220110ed0d v0.16.2: changelog and README
Release / build (push) Successful in 5m43s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 11m11s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 08:32:34 +01:00
Ilker ManapandClaude Opus 5.5 a88624747e A set wanted by several replicas on one node is fetched once
CI / test (push) Failing after 7m23s
Single files were shared between the shards and replicas asking for them
at the same time; sets were not, and four replicas on one node's four cards
wrote the same .part files over one another until all but one failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 08:29:25 +01:00
Ilker ManapandClaude Opus 5.5 540b518fde Rate limits counted across the control planes with router.active_active
CI / test (push) Successful in 9m30s
Each router's per-counter request and token counts, this minute's and the
last, ride the standby's two-second exchange with the leader. The leader
answers each standby with everyone else's sum, its own included, and puts
the standbys' sum in force on its own router; each limiter adds the
others' counts to its sliding window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 17:30:08 +01:00
Ilker ManapandClaude Opus 5.5 95fb5eeb6e BACKLOG: O2 tried on the PoC, two routers carry 1.5x the leader alone
CI / test (push) Successful in 9m26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 16:51:54 +01:00
Ilker ManapandClaude Opus 5.5 8b7eefef16 Standbys that answer the client API themselves, behind router.active_active
CI / test (push) Successful in 9m35s
A standby follows the leader's Ready replicas over the agent port, every
two seconds, and reports back the replicas its router used and the
on-demand models it was asked for. No engine keys travel: every control
plane derives them from the cluster secret. Budgets are raised to the
store's every 30 s. Unheard from the leader for a minute, a standby
passes /v1 on as before. Off by default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 13:47:47 +01:00
Ilker ManapandClaude Opus 5.5 f664592948 BACKLOG: N8 half done, ctl model context takes Strata and the longest answer alone
CI / test (push) Successful in 9m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:51:26 +01:00
Ilker ManapandClaude Opus 5.5 31f440ab6e A served model's longest answer changed in place, and Strata's context
CI / test (push) Successful in 9m29s
max_output on POST /api/v1/models/{name}/context, --max-output on ctl model
context, and a field in the console's Context length… form. Alone it changes
only the catalog's limit, which the router holds max_tokens to, and starts no
replica again; with a context it is set with it, never past it.

Strata's context is now changed like the others', through its setup's
--context, which writes the engine's start script at each start. Neither
needs a model removed and added again any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 00:15:38 +01:00
Ilker ManapandClaude Opus 5.5 f4b34dbf11 FreeToken, an engine for models larger than the card
CI / test (push) Successful in 9m37s
FreeToken (FlashML-org/FreeToken) serves mixture-of-experts models several
times a card's size from one card, experts in RAM, from Hugging Face
safetensors checkpoints. Installed like vLLM, from PyPI by uv, on every node
it runs on: compute capability 8.0+, driver 580+, x86-64, and the CUDA 13
toolkit it compiles kernels with. Older cards are passed over: on cuda01's
Quadro RTX 6000 (7.5) 0.2.1 had no kernel image for the first prefill, and
in fp16 hit an illegal memory access on its first request.

ft serve gets the model, address, card and served name; the key goes in
FREETOKEN_API_KEY. Flags that read files or fetch are refused, as are
FREETOKEN_ variables naming paths or the agent's own settings. ctl model
context sets --max-seq-len-override.

Readiness now also waits for an engine whose /health answers 200 while its
body says "loading", as FreeToken's does from the moment its HTTP server is up.

FreeToken is followed release by release: a weekly workflow fails when PyPI
has a version newer than DefaultEngines installs (scripts/engine_releases.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:10:05 +01:00
Ilker ManapandClaude Opus 5.5 c866b3a8ee How much of each GPU works, beside whether anything does
CI / test (push) Failing after 6m42s
The busy figure is nvidia-smi's utilization.gpu: the share of time any
kernel ran, which a kernel on one SM fills. On cuda01 decode read 92% busy
while the SMs had work 69% of the time and issued on 18% of their cycles.

Busy is now said to be that, and beside it: each card's draw against its
power limit (efficiency table, fleet and node charts, the console's GPU
work chart), and where the profiling counters can be read, SMs active, SM
occupancy, tensor cores and DRAM (llmgw_gpu_*_active_percent), from NVML's
GPM on Hopper and later or a dcgm-exporter on the node
(discovery.dcgm_exporter). The GPU map colours by work by default and keeps
busy as a mode. A MIG instance is never published with its card's profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:11:26 +01:00
Ilker ManapandClaude Opus 5.5 7bfe70c8df BACKLOG: the router profiled, the write per token removed, what is left
CI / test (push) Successful in 9m22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 15:50:15 +01:00
Ilker ManapandClaude Opus 5.5 5550fefaad Stream events already in hand go to the client in one write, not one each
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 15:30:19 +01:00
Ilker ManapandClaude Opus 5.5 808c4724d2 BACKLOG: O1 measured, the router's ceiling on the PoC
CI / test (push) Successful in 9m22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 10:45:59 +01:00
Ilker ManapandClaude Opus 5.5 9ff49cad8e Requests missing the cache for the same key at once share one read and one allowance
CI / test (push) Successful in 9m51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 09:30:18 +01:00
Ilker ManapandClaude Opus 5.5 524f029603 External providers left out of admission's saturation; the scale-out section in the backlog
CI / test (push) Successful in 9m25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 19:13:15 +01:00
Ilker ManapandClaude Opus 5.5 be54f1617b A mirror download cut off carries on from where it got to, and is tried again
CI / test (push) Successful in 10m36s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 17:47:32 +01:00
Ilker ManapandClaude Opus 5.5 812eb8aac1 Regenerate the admin API document for the fetch phase
CI / test (push) Successful in 9m33s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 15:07:16 +01:00
Ilker ManapandClaude Opus 5.5 76746715ec A set's files checked four at once, the check said as it goes and not taken for a stuck download
CI / test (push) Failing after 6m51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 14:58:44 +01:00
Ilker ManapandClaude Opus 5.5 d5dcf78836 A set's download reported as the whole set's, not the part being fetched
CI / test (push) Successful in 9m15s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 14:48:45 +01:00
Ilker ManapandClaude Opus 5.5 a5fefbdaaa v0.16.1: changelog and README
Release / build (push) Successful in 6m32s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 12m33s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 13:14:25 +01:00
Ilker ManapandClaude Opus 5.5 2eb96f4872 The plan says why Strata's DeepSeek does not run here
CI / test (push) Successful in 11m38s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 13:09:09 +01:00
Ilker ManapandClaude Opus 5.5 f41c96d30f Strata's DeepSeek not offered on cards its engine was not built for, and refused there by the agent
CI / test (push) Successful in 9m42s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 13:00:21 +01:00
Ilker ManapandClaude Opus 5.5 104b60fbf8 A repository's files read from every directory, not only its top
CI / test (push) Successful in 11m40s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 12:16:11 +01:00
Ilker ManapandClaude Opus 5.5 845de8eef4 The mirror page's Strata tab; Strata's ready-made packs served with Strata fetching them, strata-ple named
CI / test (push) Successful in 10m35s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 12:06:46 +01:00
Ilker ManapandClaude Opus 5.5 4ee9ba54ef v0.16.0: changelog and README
Release / build (push) Successful in 6m6s
Release / notes (push) Successful in 5s
CI / test (push) Successful in 11m14s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 11:21:32 +01:00
Ilker ManapandClaude Opus 5.5 12464ce0f6 Cluster upgrades: nodes told together with progress said, done when back on the version, the download kept past a broken stream
CI / test (push) Successful in 10m2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 11:14:02 +01:00
Ilker Manap d6f8f433f4 CHANGELOG: one list
CI / test (push) Successful in 9m18s
2026-10-30 10:39:30 +01:00
Ilker ManapandClaude Opus 5.5 7465c24830 Strata's families and sizes known: checked when a model is added, its memory reserved from them
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 10:17:42 +01:00
Ilker ManapandClaude Opus 5.5 91f668fe7e Strata named by hand is reported, its engine stopped with it, its memory required, Pascal allowed
CI / test (push) Successful in 9m28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 09:49:16 +01:00
Ilker ManapandClaude Opus 5.5 73091d7c97 A cached file checked as another is refused and left, not deleted for the next model
CI / test (push) Successful in 9m19s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 08:17:43 +01:00
Ilker ManapandClaude Opus 5.5 b8ed368e31 Weights from Hugging Face pinned to a commit and checked against the sha256 it published
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 07:33:15 +01:00
Ilker ManapandClaude Opus 5.5 29d7f54cfd A path escaped as it came reaches the leader through a standby; F5 in the backlog
CI / test (push) Successful in 9m30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 22:54:27 +01:00
Ilker ManapandClaude Opus 5.5 cacaa35c3f The gateway embeds the documents llmgw-kb sends as text
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 21:56:05 +01:00
Ilker ManapandClaude Opus 5.5 01ad8c9207 v0.15.3: changelog and README
CI / test (push) Successful in 11m44s
Release / build (push) Successful in 5m43s
Release / notes (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 20:54:53 +01:00
Ilker ManapandClaude Opus 5.5 59187cdae5 A model can be stopped and kept, or removed with its files from the mirror and the nodes
CI / test (push) Successful in 10m37s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 20:45:48 +01:00
Ilker ManapandClaude Opus 5.5 5726ceb84e BACKLOG: J4 done, tried on the PoC with the first mirror stopped
CI / test (push) Successful in 9m21s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 20:01:47 +01:00
Ilker ManapandClaude Opus 5.5 435ffe1171 A file removed outside the running mirror is no longer reported, and its removal reaches the peers
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 19:25:00 +01:00
Ilker ManapandClaude Opus 5.5 785e06ecbf A mirror with peers records the checksums of files that had none, so they are copied too
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 18:36:59 +01:00
Ilker ManapandClaude Opus 5.5 0033a530c7 mirror add-peer on a mirror machine of its own says what to run on a control plane
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 18:30:30 +01:00
Ilker ManapandClaude Opus 5.5 8cd376615b v0.15.2: changelog and README
Release / build (push) Successful in 4m44s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 9m18s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:46:15 +01:00
Ilker ManapandClaude Opus 5.5 e1cff0d4e3 A served model can be moved to more cards, or fewer
CI / test (push) Successful in 10m28s
llmgw ctl model gpus MODEL N [--profile P] [--in-place],
POST /api/v1/models/{name}/gpus and the console's GPUs…: how many cards of
one node each replica uses. Tensor parallel for vLLM, SGLang and
TensorRT-LLM, held to the model's KV heads (modelplan.SplitsHeads, now one
rule for the planner and this); the layers split for llama.cpp, its old
--tensor-split dropped. Refused for a count no node has, for cards that
would not hold the weights and the context, and for engines on one card.
The replicas start again beside the old ones; with no room beside them the
command says so, and --in-place stops each first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 16:40:49 +01:00
Ilker ManapandClaude Opus 5.5 51aeadae3e v0.15.1: changelog and README
Release / build (push) Successful in 6m4s
Release / notes (push) Successful in 5s
CI / test (push) Successful in 11m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:59:12 +01:00
Ilker ManapandClaude Opus 5.5 986981ac81 A served model's context can be changed, engine and memory with it
CI / test (push) Successful in 12m0s
ctl model context MODEL TOKENS [--slots N], POST /api/v1/models/{name}/context
and the console's Context length…: the catalog, the engine's arguments and
the KV memory change together, refused past the trained context or what the
cards hold, then the replicas restart one by one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 15:50:04 +01:00
Ilker ManapandClaude Opus 5.5 1908bfb4b2 Strata on cuda01 end to end: its engine is the shard's, its metrics read
CI / test (push) Failing after 7m31s
Found running it through the cluster:

- Strata's server starts its engine in a process group of its own, so the
  agent saw a GPU replica on the CPU and its 22 GB as somebody else's
  memory. A process the shard started, at any remove, is now the shard's.
- An adopted shard's kept compute was sent at once and never again, so a
  wrong one read again on adoption never reached the control plane: what
  is sent is sent again when it changes, and a GPU shard kept as the CPU
  is read again on adoption.
- /metrics/prometheus is llama.cpp's names without the KV cache; vLLM's,
  with it, are /metrics?format=prometheus. The router now sees running 1,
  waiting 2 under load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:48:15 +01:00
Ilker ManapandClaude Opus 5.5 c0d5194ea0 Strata runs as an engine: a 125B MoE on one card
Strata (github.com/Niko1221/Strata) keeps a mixture-of-experts model's
experts in RAM and the ones used most in VRAM; on cuda01's RTX 6000 it
serves Qwen3.8-Flash-Next 125B at 70-87 tokens a second to one client.

- engines/strata.go: the adapter. The agent starts llmgw strata-serve,
  which runs Strata's setup the way its desktop app does
  (STRATA_CONTROL_INSTALL: installs nothing, writes only under the
  replica's and the node's directories) on the fetched GGUF files, points
  the configuration it writes at the installed engine, and execs Strata's
  server. The key goes in STRATA_API_KEY, never on a command line.
- A profile's args go to setup: --family, --size (setup's --model, which
  is the source everywhere else), --context, --parallel, ... Flags that
  move files or pick the engine or GPU are refused.
- provision: the source pinned by digest in a Python environment, and the
  release's prebuilt engine unpacked beside it, checked against the digest
  GitHub publishes: CUDA 13 (7.5+, driver 580) or CUDA 12.9 (6.0-8.9,
  driver 575). Python builds can now carry release assets.
- Metrics are vLLM's names under /metrics/prometheus.
- The catalog wants one card and a family, and a directory, not one .gguf.

Opt-in: not in DefaultEngines.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 15:09:13 +01:00
Ilker ManapandClaude Opus 5.5 eb9c448d68 BACKLOG: J4 tried on two machines
CI / test (push) Successful in 9m20s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 15:04:11 +01:00
Ilker ManapandClaude Opus 5.5 1c16f2337d A second mirror: llmgw mirror add-peer, and on a store machine at setup
CI / test (push) Successful in 9m29s
mirror add-peer, run on the mirror's machine, sets up a mirror on
another over ssh, kept in step with this one: llmgw there if it has
none, mirror init --peer, this mirror told of it and allowed to be read
by it, and mirror.peers in the cluster's settings. The setup screens
offer one on either database machine, as server init --second-mirror.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 13:43:27 +01:00
Ilker ManapandClaude Opus 5.5 8a76a53afb The control plane uses whichever mirror answers
mirror.peers names the mirrors kept in step with mirror.api. The
mirror's management calls go to the first that can be reached, and
each mirror is asked every fifteen seconds whether it answers: a shard
is sent its model's and adapters' address on one that does while the
mirror the catalog names is down.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 13:43:27 +01:00
Ilker ManapandClaude Opus 5.5 5bc71b3d0a Mirrors kept in step: llmgw mirror serve --peer
Each mirror reads its peers every thirty seconds and copies the
versions a peer holds whole that it lacks, keeping a copy only if its
checksum is the peer's. A removal is kept with its time (.removed.json)
and listed for peers: a version removed while a peer was down is removed
there when it is back, not copied back from it; one added again after
its removal stands on both. Neither mirror leads.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 13:43:27 +01:00
Ilker ManapandClaude Opus 5.5 c09d5ad4ee Outside services read answers redacted and whole; a card's own lanes
CI / test (push) Successful in 9m16s
From the second review.
- An output service reads the answer with the values restore put back
  taken out again (guard.Subject.Restored), as an input service reads the
  request redacted.
- With n above 1, the service reads the first choice once it is whole,
  not at whichever choice finished first.
- Restored values go into an answer asked for as JSON (response_format)
  escaped for it, as into tool-call arguments.
- The embedding failure is logged once a minute through an outage, not
  once.
- A card's own lane count and fastest generation come from the kernel
  (max_link_width, max_link_speed): the driver's width.max is what card
  and slot allow together, so an x16 card in an x8 slot read x8 of x8 and
  was never said.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 13:22:39 +01:00
Ilker ManapandClaude Opus 5.5 1948a787ae Fixes from the second review: grants, flags, updates, setup
CI / test (push) Successful in 9m15s
- Revoking a server's tools takes them from every level beneath too, and a
  level left naming no tool stays closed ([]) while any level above or
  beneath names tools: an organization's revoke left its teams the copies
  their first grants took, and going back to no list opened the level to
  what was above it. A closed level is shown on the page and in the
  console with an action to open it (store.OpenTools). A key's owner's
  list is among the levels above a key.
- The flag check matches short flags as written again (-m, -hf, -mu were
  let through), takes a long flag's beginning for it only for the engines
  that parse with argparse (vLLM, SGLang, ComfyUI: --li is --listen), so
  llama.cpp's own --chat-template is not refused, and refuses llama.cpp's
  short spellings (-hfr, -hff, -mm, -jf, …).
- A stored model with an adapter by Hugging Face id can be changed again:
  what it already had is not asked about anew.
- The catalog reads a Hugging Face id as the agent does (engines.IsHubID).
- The page's form cannot move llmgw:admin to another address.
- server init skips the memory check only where the gateway already runs,
  not wherever a half-finished first run left server.yaml.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:31:07 +01:00
Ilker ManapandClaude Opus 5.5 ecf5545a7a VS Code's sign-in keeps its place; recordings checked for secrets
CI / test (push) Successful in 9m14s
From review.
- /profile asked before signing in keeps its query for ten minutes, and
  the sign-in (password or OIDC) returns to it: VS Code's sign-in state
  came back lost, and the confirmation came every time. Only /profile is
  ever returned to.
- The extension keeps an https gateway at https when a link names it by
  plain http (a proxy ending TLS unannounced), so the key is never sent in
  the clear. The install command says --force, since dev builds share a
  version and VS Code kept the old one.
- Every frame of every install recording is checked for a key, a
  password or a private key, not only the page.
- The admin API document again matches the API: f94d902's
  MCPServerRequest.clear was not in it.
- CHANGELOG for this round's fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:40:19 +01:00
Ilker ManapandClaude Opus 5.5 258591b3f2 What an engine downloads by a Hugging Face id is asked about too
CI / test (push) Failing after 8m0s
From review. An adapter given by its id is downloaded by vLLM itself, and
a TensorRT-LLM model by its id by TensorRT-LLM, past the node's check and
not held to safetensors: a model with either is refused when it is added,
unless it says allow_pickle, with how to mirror its safetensors instead.
vLLM's own model by id stays allowed: the agent gives it --load-format
safetensors. A test holds every preset to its own pickle check.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:06:11 +01:00
Ilker ManapandClaude Opus 5.5 5eac62d556 Guardrail services merged the stricter way; the request log keeps no restored value
CI / test (push) Failing after 7m44s
From review.
- A service two policies name is asked the stricter way of the two, both
  directions and block over flag, rather than as the first one says.
- A service answering null where it says unsafe gave no answer, not safe.
- With restore: true, the kept answer had the request's redacted values in
  it beside the redacted request; they are taken out again before it is
  kept.
- guardrails.md says what fail: open lets through, and that of n answers a
  service reads the first.
- The similar cache's embedding failures are logged once a minute, not per
  request; the page's test reads the gateway's local MCP handler under its
  lock; org/name:Q4_K_M is not taken for a Hugging Face id to read as
  safetensors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 11:03:57 +01:00
Ilker ManapandClaude Opus 5.5 f94d902cc3 MCP servers: fields that can be emptied, addresses without passwords
CI / test (push) Failing after 6m34s
From review. A server's description, timeout and rate can be emptied from
the console and the page (MCPServerRequest.Clear), where an empty field
used to leave them as they were. An address with a user and password in
it is refused (adminapi.CheckMCPAddress): the credential belongs in a
header, sealed, and the address is shown and audited as written. The
console accepts llmgw:admin and the page lets it be changed, address
fixed. A grant to a key that is not there is refused rather than said
done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 10:03:27 +01:00
Ilker ManapandClaude Opus 5.5 721705b29d A card in the wrong PCIe slot is said so
CI / test (push) Successful in 9m17s
At the user's asking: we know what a card's link could be, so a card
linked slower is reported. Each agent reads, apart from the GPU query so
that a driver not knowing a field costs nothing else, the fastest PCIe
generation card and slot allow together, the card's own, and the lanes in
use and the card's most (nvidia-smi; NVML in the nvml build). The current
generation is not compared: it drops while a card idles. A card on fewer
lanes, or a slot of an older generation, is a node warning with the GB/s
it carries against what it could — on the dashboard, the console and in
llmgw doctor, which points at lspci's LnkCap and LnkSta. MIG instances are
said of their card. GPU proto fields 23-26.

The PoC's Quadro RTX 6000 reads Gen3 of Gen3, x16 of x16, idle at Gen1:
nothing to say.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:49:16 +01:00
Ilker ManapandClaude Opus 5.5 6a870a8c15 The explorer's card figures: MIG, NVLink, GH200, FP8 and FP4
CI / test (push) Successful in 9m13s
From review. A MIG instance is given its share of the card's bandwidth,
not the whole card's. NVLink between a layout's cards means all of them
joined to each other, the largest such set on one ready node, not a count
of cards with any peer: two bridged pairs are not four on NVLink. GH200 is
matched before H200. An FP8 checkpoint is listed for cards of 8.0 and up,
which vLLM runs with Marlin; 8.9 is the agent's bound for --quantization
fp8. NVFP4 repositories tagged fp4 are sized at four bits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:38:38 +01:00
Ilker ManapandClaude Opus 5.5 b986ea180d Fixes from review: setup's memory check, MCP secrets, VS Code, bandwidth
CI / test (push) Successful in 9m47s
- server init run again on a machine already set up is not refused its
  memory (the docs fix a certificate with --host so), and judges count
  only when one will be installed (judgeComes: not without a mirror, nor
  from a bundle without it).
- A server's sealed secrets that it no longer reads are removed when it is
  put or deleted; replacing a credential replaces every header it was
  sent, rather than adding one beside the old.
- llmgw.gatewayUrl is a machine setting and restricted in untrusted
  workspaces: a cloned repository's settings cannot send a sign-in to
  another gateway.
- The bandwidth buffer is at least eight times the CPU's last cache and at
  most a quarter of free memory, else nothing is measured until there is
  room; a registration carries the figure measured so far.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:23:00 +01:00
Ilker ManapandClaude Opus 5.5 54c6b66cb9 Fixes from review: the similar cache, restore, outside services, latency
CI / test (push) Successful in 9m48s
- The similar-question cache embeds a question as its project would: a
  project kept in the cluster has external fallbacks refused, and an
  embedding model outside the cluster is not asked at all. The vectors are
  compared outside the cache's lock. The question's bound is in characters.
- The restorer puts values back in streamed tool calls' arguments (escaped
  for the JSON they are), completions' text and reasoning, each held across
  events on its own, and releases a held tail with the finish event. A
  whole answer is walked as JSON, so a value with a quote no longer breaks
  tool-call arguments.
- An outside guardrail service reads the user's turn as redacted, and a
  streamed answer once rather than at the finish chunk and again at the
  end. guardrails.md says it can stop a stream only under buffer_all.
- A replica's smoothed time to first token is forgotten after five minutes
  without an answer: one found slow and then given nothing was never tried
  again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 08:15:43 +01:00
Ilker ManapandClaude Opus 5.5 fd94265e08 Pickles refused however they come; a string temperature passes again
CI / test (push) Failing after 7m17s
From review. A mirror set already on the node is looked at again, not
trusted for having been fetched once; Pickled cuts a query or a fragment
from any source, hf: too; .pt2 is a pickle. The flag check reads a flag as
the engine does: underscores as dashes and any beginning of a refused flag
as that flag, since argparse takes --trust-remote for --trust-remote-code;
SGLang refuses --load-format, which would undo the agent's safetensors.

TripoSR's preset says allow_pickle: without it the preset could not be
added and the presets pass failed on it each time. A shared ComfyUI
environment's hidden entry takes its first member's allow_pickle, as it
takes its source.

The GenAI trace attributes made temperature and top_p typed fields of the
request's parse, so "0.7" as a string was a 400; they are read loosely,
as they are read only for the trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:19:06 +01:00
Ilker ManapandClaude Opus 5.5 44451bacd9 Granting or taking back a server's tools leaves the rest as it was
CI / test (push) Successful in 10m29s
A tool is seen where every level above that names tools names it (store
Effective.AllowsTool). The console and /admin/mcp wrote a level's list as
given, so a team that saw its organization's hr.* through having no list
was given jira.* and then saw neither; and revoking a level's last server
left [] in the store, closed to every tool, shown nowhere.

store.GrantTools and RevokeTools now do both screens' work: a level's first
list starts from the nearest list above it; taking a server away leaves the
others, and a level left naming nothing goes back to no list, or stays
closed when a level above names tools, so it opens to nothing it was kept
from. GrantTools says which levels above still keep the granted tools out
of reach, and both screens say so. The page shows closed levels and member
scopes by name, and offers no revoke for a * grant it would not change. The
console calls the store instead of keys limits with a list built from what
it had loaded, which overwrote changes made since. Found in review.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:11:07 +01:00
Ilker ManapandClaude Opus 5.5 7a04248148 A reused answer never carries another caller's values
CI / test (push) Successful in 9m50s
With restore: true and the answer cache on, alice's "write to
alice@example.com" and bob's "write to bob@example.com" redact to the
same request, and bob was given the answer kept for alice, her address put
back in it. A request with restore values is now never looked up in the
cache nor kept in it.

The similar-question cache compared the last message's text and blanked
its parts: two pictures asked about in the same words were one question.
A last message with any part that is not text is now reused only when the
request is the same.

Found in review; both tests fail without the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 22:03:32 +01:00
Ilker ManapandClaude Opus 5.5 03d3bcfd2d The company's MCP servers on a web page
CI / test (push) Successful in 9m8s
/admin/mcp, for administrators only, since a server's credential reaches
whatever it guards: each server's state, who has been granted its tools,
its rate, the headers it is sent by name with the sealed ones marked, and
its address; adding one with a bearer token or another header kept in the
store; testing it with its credential and listing its tools; granting them
to a team, project, org, person or key, written into that level's limits
beside its other grants and its rate and budget; revoking; changing;
replacing the credential; turning off and on; removing; and how a person
points an agent at the gateway's /mcp with a key of their own. English and
Turkish. The secret's field is named so the audit line redacts it.

The admin API's server merge and test are package functions now,
adminapi.MCPServerFrom and adminapi.TestMCPServer, shared by both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 19:26:51 +01:00
Ilker ManapandClaude Opus 5.5 98c45ed28b The company's MCP servers in the console
CI / test (push) Successful in 9m6s
A screen beside Knowledge: each server's state, who has been granted its
tools (read from the store's limits), its rate, the names of the headers
it is sent but never their values, and its address. A adds one, with a
bearer token or another header sent once and kept sealed. On a row: test
it and list its tools; grant them to a team, project, org, person or key,
merged with what else that level is granted, since keys limits --tools
writes the list whole; revoke; change; replace the credential; turn off or
on; remove. The screen also says how an agent is pointed at /mcp with a
person's own key.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:23:25 +01:00
Ilker ManapandClaude Opus 5.5 89435735de BACKLOG: the open list as it stands after v0.15.0
CI / test (push) Successful in 9m1s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 11:16:49 +01:00
Ilker ManapandClaude Opus 5.5 a583359ebf Agents started together measure their memory apart
CI / test (push) Successful in 9m12s
Seven VMs on one host restarted at once read a third less each than any
gets alone. An agent now waits 10 s to 2 min, at random, before measuring,
and measures again every hour, keeping the best.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 09:56:02 +01:00
Ilker ManapandClaude Opus 5.5 f2c6f12cfb How fast a model would answer on the cards, as this cluster would lay it out
CI / test (push) Successful in 9m1s
For each layout modelplan.Options offers here — one card, tensor parallel
across a node's cards, llama.cpp's layer split, a pipeline across nodes on
a fabric — modelplan.GPUTokensPerSecond gives one reply's speed: the bytes
a token reads over each card's bandwidth, by its model name (a table, SXM
and PCIe apart), plus two all-reduces a layer for tensor parallel (8 us on
NVLink, 25 us and up on PCIe), turns between cards for a layer split, and
a hop per node over InfiniBand or RoCE for a pipeline. The explorer and a
repository's files show the fastest, with the layout; mixed classes are
judged class by class, FP8 on 8.9 cards only.

Calibrated on cuda01's Quadro RTX 6000 (672 GB/s): qwen3-vl-8b Q8, 8.7 GB,
62.1 tok/s measured, 59 estimated; Qwen3-Coder-30B-A3B Q4_K_M, 18.6 GB,
144 measured, which says a model of experts reads 1.6 times its active
share (ActiveShare now does, on the CPU too). Tensor parallel and fabrics
are not measured here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 00:19:36 +01:00
Ilker ManapandClaude Opus 5.5 e70d6caa2f The memory is measured after the agent reaches its control plane
CI / test (push) Successful in 9m5s
Measuring it in the startup scan held every agent half a second or more
before registering, which on a shared CI runner was past the failover
tests' wait. Now it runs once a process, in the background, the first time
it is asked for, and Heartbeat.mem_bandwidth_gbs carries it when known;
the control plane keeps the last figure and a heartbeat without one leaves
it. The agent's tests do not measure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 15:31:21 +01:00
Ilker ManapandClaude Opus 5.5 84444e71c8 How fast a model would answer on a CPU, before it is fetched
CI / test (push) Failing after 6m37s
Each agent measures its node's memory read rate when it starts: every CPU
sums a share of a buffer past any cache, a tenth of free memory at most,
the best of four passes, in a fraction of a second; reported in Host
(mem_bandwidth_gbs) and NodeView. A token on a CPU reads every weight it
uses once, so one reply's speed is about that rate over those bytes:
modelplan.CPUTokensPerSecond, 0.68 of the measured rate plus 5 ms a token,
calibrated on the PoC against llama.cpp's predicted_per_second (node01
24.9 GB/s, qwen2.5-1.5b: 14.5 measured, 14.7 estimated; node05 41.9 GB/s,
qwen2.5-14b: 3.3 and 3.2). A model of experts named …-A3B reads its active
part (ActiveShare).

The explorer shows it as CPU tok/s, at Q4_K_M on the fastest node that
holds the model; a repository's page gives it for each GGUF by its size.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 00:09:47 +01:00
Ilker ManapandClaude Opus 5.5 2a45068461 The explorer lists what this cluster's hardware holds
CI / test (push) Successful in 8m58s
Judged by the nodes as they are now, through modelplan.Options, the
planner that serving a model uses: GGUF at Q4_K_M from the parameter count,
safetensors at the size the registry's dtype breakdown gives (packed AWQ,
GPTQ and MXFP4 at four bits), FP8 only where a card has compute capability
8.9, MLX safetensors never. A picture model is judged against one card. A
cluster with cards asks the registry for safetensors too, a hundred at a
time; one without asks for GGUF only. The page says what it judged by: the
largest card, its capability, the most RAM on one node.

On a repository's page a file set no node holds is marked with what it
needs, against what the best node offers, and has no Fetch button.

Tried against the PoC's seven nodes and Hugging Face's trending hundred:
63 shown, the rest too big for one 24 GB Turing card or FP8.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 18:30:16 +01:00
Ilker ManapandClaude Opus 5.5 e84631ce3a The explorer lists only what a node loads
CI / test (push) Successful in 9m0s
A repository with neither GGUF nor safetensors (by the registry's summary
or its tags) is hidden under "runs here", which is ticked by default, and
counted with the architectures llama.cpp cannot load; unticked it is
listed. An open repository's pickle files are not offered, only counted,
and a repository of pickles only says to look for another version.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:00:28 +01:00
Ilker ManapandClaude Opus 5.5 4466d2d15a Weights that run code are refused
CI / test (push) Successful in 8m57s
Python pickles (.bin, .pt, .pth, .ckpt, .pkl) run whatever they hold when
loaded, as the engine's user, on the node; that is how the malicious models
found on Hugging Face work. The node refuses such a source, or a mirror set
holding one, before fetching anything. A model added with one, or given
one, is refused, while one already in the catalog still loads and is
refused by its node with the same words. vLLM and SGLang downloading a
model by its id get --load-format safetensors. The mirror page marks the
file. allow_pickle: true on a model lets it through on purpose, carried to
the node in EngineProfile.allow_pickle.

BACKLOG M lists the rest: checksums against Hugging Face on every path,
its own scan, trusted publishers, engines without the internet, an eval
before an untrusted model serves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 23:17:22 +01:00
Ilker ManapandClaude Opus 5.5 6752938e6d v0.15.0: changelog and README
CI / test (push) Successful in 9m0s
Release / build (push) Successful in 4m23s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:59:44 +01:00
Ilker ManapandClaude Opus 5.5 3a3db8cd20 Answers to similar questions, not only the same one
CI / test (push) Successful in 8m52s
llmgw keys cache --similar 0.95 also gives a chat question the answer to
one like it: the last user message embedded by router.cache.embedding_model
(asked on the gateway's account, as a judge is, within 2 s), compared by
cosine with the questions answered in the same conversation — system
prompt, earlier turns, tools and sampling the same. The exact cache is
asked first; an exact hit embeds nothing. Said as x-llmgw-cache: similar
with x-llmgw-cache-similarity, charged as a hit.

The vectors live with their answers in the gateway's memory, counted in
max_bytes and evicted with them, not in a pgvector table as BACKLOG I1
planned: the cache keeps answer text out of the store on purpose. A
question over 2,000 characters is reused only when the same. Migration
0051 holds the setting. BACKLOG I1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:47:11 +01:00
Ilker ManapandClaude Opus 5.5 ed0d1ec0ad A cached prompt costs the cached rate
CI / test (push) Successful in 8m45s
cached_per_1k in a price charges the prompt tokens the answer counts as
cached_tokens at their own rate; zero keeps charging them as prompt tokens.
The count reaches the cost and not a column: CostMicros holds what it
changed. Cached beyond the prompt is clamped, an engine's miscount rather
than a refund. BACKLOG L5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:15:48 +01:00
Ilker ManapandClaude Opus 5.5 73e1bb6733 Chat goes to the replica that has been answering soonest
CI / test (push) Failing after 7m18s
The router keeps each endpoint's time to first token, smoothed over its last
streamed answers, and the chat profile scores by it beside session, queue,
KV and inflight: the fastest endpoint 1, one twice as slow 0.5, one not yet
measured 1 so a new replica is tried. Load still sees a slow drain from the
engine's own counters; this sees what answers actually took. BACKLOG L4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:01:30 +01:00
Ilker ManapandClaude Opus 5.5 0b37796067 llmgw and Kong, written down after trying Kong 3.9 in front
CI / test (push) Successful in 11m7s
docs/kong.md: a plain route or ai-proxy with llmgw as the upstream, the
client's llmgw key passed on so per-team budgets hold, Kong as a provider
behind llmgw, and Kong 3.9's streamed answers counted without completion
tokens. Closes BACKLOG L9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:55:22 +01:00
Ilker ManapandClaude Opus 5.5 9e7de57816 The reference describes a guardrail policy's services
CI / test (push) Successful in 11m42s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:50:58 +01:00
Ilker ManapandClaude Opus 5.5 783dc9e9f6 A guardrail policy can ask someone else's service
CI / test (push) Failing after 7m39s
A site may be required to use a particular guardrail: Azure Content Safety,
Lakera, NeMo, a classifier of its own. A policy's services asks one over
HTTP — a body template given the text, header values from files read each
call, dotted paths to where the answer says unsafe and why — on input,
output or both, to block or flag. On input it reads the latest user turn
before the judge; on output the whole answer once at its end. A service
that does not answer is what the policy's fail says. It is told the text
only, never who sent it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:49:56 +01:00
Ilker ManapandClaude Opus 5.5 81685ecd75 A request's redacted values are put back in its answer
CI / test (push) Successful in 8m49s
With restore: true on a guardrail policy's input, what redact rewrote as
placeholders comes back in the answer: the model reads "[EMAIL_1]", the
client reads the address it sent. A streamed placeholder split across
events is held until it is whole, and what is held is sent at the end.
The swap runs before the output filter, so the output rules judge the
answer as the client reads it and a placeholder they make of a value the
model invented is never taken for the request's own. The map lives in the
Verdict for the request only; nothing stores, logs or traces it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:41:53 +01:00
Ilker ManapandClaude Opus 5.5 d110968243 A machine too small for the models it would serve is said so first
CI / test (push) Successful in 8m42s
A control plane that is also a node, serving the starter model and two
judges, needs about 10.5 GB: 3.5 for itself, 2 for the starter, 2 for each
judge, and one more because the scheduler counts whole gigabytes. On 8 GB
the install did every step and then had nowhere to run the starter. The
setup's checks now fail this machine's memory and name what it would serve,
and server init refuses before it installs anything, naming the flags that
leave those models off it. quick-install.md said 8 GB; it says 12.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:19:00 +01:00
Ilker ManapandClaude Opus 5.5 06baa38858 Traces say model calls in the OpenTelemetry GenAI conventions
CI / test (push) Successful in 8m55s
An inference request and each attempt at an engine carry gen_ai.*: the
operation, the requested and answering model, max_tokens, temperature and
top_p when the client set them, the session as the conversation id, the
input, output and cached tokens, and on an attempt the engine that answered.
A customer's Grafana, Jaeger or Langfuse then shows llmgw's calls as model
calls. No prompt or answer text is ever put in a span, and a test says so.

BACKLOG gains section L: what is worth taking from Kong's AI Gateway, of
which this is the first.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 12:59:20 +01:00
Ilker ManapandClaude Opus 5.5 38dfe268c9 VS Code gets the gateway's models from an extension the gateway serves
CI / test (push) Successful in 8m43s
llmgw's own extension for VS Code, plain JavaScript with no dependencies,
packed into a .vsix by the binary at /setup/vscode/llmgw.vsix with the
serving gateway's address in it, so it needs no build, no Node and no
marketplace. It registers llmgw as a language-model provider: the chat's
model picker, agent mode and any extension that asks VS Code for a model
get every chat model the key may use, sized and with tool and vision
capabilities as the catalog says, streamed, tool calls both ways.

VS Code is a tool on the account page's Connect your editor: a key, the
install command, and an Open in VS Code link that hands the key to the
extension's secret storage. The extension's Sign In opens the page with a
state of its own; a link without it is confirmed, the gateway named, since
it decides where code is sent.

Tested in VS Code 1.138 against the PoC: 27 models listed, an answer from
qwen2.5-0.5b-instruct, and qwen3-coder-30b calling a tool and using its
result (internal/vscodeext/e2e).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:39:50 +02:00
Ilker ManapandClaude Opus 5.5 fea5774fe5 The install guide shows every screen of a real install
/install takes a reader from three bare Ubuntu machines to a first answer:
what each machine needs and the commands that check it, the release fetched
and its checksum checked, the eight setup screens, the system's state and
the console, and a first request with a key from the account page. Every
screen is from an install of v0.14.9 on three fresh VMs, recorded with tmux
and kept as terminal frames: the page shows them as text, and plays a
session back when asked, fetching it compressed from /install/cast/. The
administrator's password and the API key were masked before the recording
was kept, and a test fails if either shape reaches the page.

/about gains what llmgw now does beyond routing, and its install steps are
today's: the setup screens and node add, not onboard-node.sh and a model
file, with a link into the guide.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:35:08 +02:00
Ilker ManapandClaude Opus 5.5 f8d18efaef Turkish pages are Turkish to the end
CI / test (push) Successful in 5m35s
The errors other packages give a page (store, catalog, mirror) are
shown in its language; a percentage is written as the language writes
it (%93 in Turkish); the footer's time in its date form; and a stale
form is refused with a sentence rather than "bad request".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 09:22:42 +02:00
Ilker ManapandClaude Opus 5.5 3a4488b41e The company's MCP tools under the guardrails, a rate and a breaker
CI / test (push) Successful in 5m35s
A tool's answer is read by the caller's policies as the prompt it
becomes, so an instruction planted in a ticket is withheld and personal
data redacted; its arguments are read before they leave. ctl mcp add
--rate bounds each caller's calls a minute to a server (migration 0050),
and five failures in a row take a server out for a minute, its tools
off the list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 23:00:22 +02:00
Ilker ManapandClaude Opus 5.5 6bef138c23 The judge check gives each judgment a text of its own
CI / test (push) Successful in 5m32s
The same text again was answered from what the engine kept of the last
reading, in a tenth of the time: the check read 4.9 s, then 0.5 and 0.5.
Each judgment now begins with a line of its own.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 22:45:58 +02:00
Ilker ManapandClaude Opus 5.5 85f135311e How fast the judge is, measured, and said at install
CI / test (push) Successful in 5m29s
ctl guard judge check, and POST /api/v1/guard/judges/check, time three
judgments of a 280-token prompt, then a burst at once, against the
policies' wait: ok, tight or slow, about how many requests a second can
be judged, and what to do. server init runs it once the judge is up and
says a machine too slow before the next steps; the wait is not changed
without asking. BACKLOG reordered by what goes wrong where installed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 22:35:19 +02:00
Ilker ManapandClaude Opus 5.5 d1bf26528b keys limits changes only the limits it is given
CI / test (push) Successful in 5m25s
Setting one limit at a scope wrote the whole row, so a tool grant given
alone cleared the scope's rate, budget and models. --replace writes it
whole, as the console's form, which shows every field, now asks for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 17:32:43 +02:00
Ilker ManapandClaude Opus 5.5 4eb49d0e4c The cluster as a read-only MCP server, llmgw:admin
CI / test (push) Successful in 5m26s
Its tools say what llmgw ctl does: cluster_status, list_nodes,
list_models, list_replicas, explain_placement, node_logs; none changes
anything. The admin API serves it at POST /api/v1/mcp, and the gateway
answers a server registered as llmgw:admin in its own process, so no
token is kept and each control plane serves it. ctl mcp add takes
--bearer-file for a token sent as Authorization: Bearer.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 17:18:28 +02:00
Ilker ManapandClaude Opus 5.5 4453f469e1 Company MCP servers are registered with ctl mcp, and tools granted with keys limits --tools
CI / test (push) Failing after 5m4s
llmgw ctl mcp add|list|remove|test and /api/v1/mcp/servers keep the
servers; a token given with --secret-file is kept in the store, sealed,
and named by reference. keys limits --tools, and the console's limits
form, grant tools as patterns. Documented in install.md and clients.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 12:50:55 +02:00
Ilker ManapandClaude Opus 5.5 ca77637a71 /mcp serves the company's own MCP servers' tools to keys granted them
Each registered server's tools are listed as server.tool beside llmgw's
own, to a key whose tool grants match, and called with the server's
credentials, read from their secret references at each call; the
server is told the key, person and project calling. A call is bounded
in time and size, its progress passed on to a streaming client, and
recorded in the audit log with its arguments' size and digest, never
their text. A key granted nothing sees none of them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 10:53:24 +02:00
Ilker ManapandClaude Opus 5.5 765b6f5603 An MCP client for the company's tool servers
internal/mcpclient speaks streamable HTTP: initializes once and keeps
the session the server gives, beginning it again once if the server
forgot it; lists tools page by page; calls one with headers of its own,
taking the answer as JSON or as events with progress before it; and
bounds what it reads. mcptest is a server for tests.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 10:43:35 +02:00
Ilker ManapandClaude Opus 5.5 6dfdfbf2fe The store keeps the company's MCP servers, and which tools a scope may call
mcp_servers holds each server: its address, headers, and secret
references for the headers that carry credentials. limits gains
allowed_tools, patterns such as jira.* resolved down the tenancy tree;
unlike models, a tool no level grants is not granted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 10:36:08 +02:00
Ilker ManapandClaude Opus 5.5 8ffde2b80c A big model's download no longer runs out its startup timeout
CI / test (push) Successful in 5m18s
The startup timeout runs from when a node's download last moved, so a
model of hundreds of gigabytes on a slow mirror is not failed while it
arrives. ctl model serve --env sets engine variables (NCCL_DEBUG=INFO)
on every node, and a layout given by hand keeps expert parallelism for a
model of experts. docs/large-models.md: how a model is split, what each
way needs of a card, and a checklist for bringing one up on a fabric.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 09:45:31 +02:00
Ilker ManapandClaude Opus 5.5 4fe9b46451 A plan for a big model: experts spread, any number of nodes, memory said
A mixture-of-experts model on more than one card is served with vLLM's
--enable-expert-parallel; config.json's experts are read by any of their
names, and a model that also reads pictures is sized by its text_config.
Tensor parallelism past the KV heads is allowed when it is a multiple of
them (Qwen3-235B, four KV heads, runs on eight cards), which the plan
had refused. Several nodes are offered in any number, not only powers of
two. Each way says what it needs of a card, part by part, and of all its
cards; the serve form checks a context longer than planned still fits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-20 08:45:33 +02:00
Ilker ManapandClaude Opus 5.5 314cc5e6a3 The judge init installs is no longer replaced by the leader's own
CI / test (push) Successful in 5m27s
Found on a fresh HA install with a judge machine: the leader saw the
policy's judge missing while init was still fetching it, served one
itself, and its entry replaced init's, so a second judge ran off the
judge machine. The keeper now waits ten minutes after it starts, and a
serve job it starts gives way to a model added meanwhile (AddNew).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 20:37:10 +02:00
Ilker ManapandClaude Opus 5.5 34764f5ef0 A node given a role takes only what asks for it
CI / test (push) Successful in 5m24s
The judges' machine was selected by the judge's profile, but any other
model could still be placed beside it. A node labelled llmgw.role now
takes only profiles whose selector asks for that role.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 17:52:30 +02:00
Ilker ManapandClaude Opus 5.5 51ca09287f A three-machine install can give the judges machines of their own
CI / test (push) Successful in 5m10s
The setup screen asks for up to two judge machines after the store's
two, and server init takes --judge-node for each. Each is added as a
node labelled llmgw.role: judge, and the judge's profile selects that
label, with cores by init's rule over those machines. A judge beside a
busy control plane refused 38 of 60 requests under load; alone, 2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 17:45:00 +02:00
Ilker ManapandClaude Opus 5.5 8b122aae09 A judge the leader serves by itself is sized as init's is
CI / test (push) Successful in 5m13s
A judge a policy names and the catalog lacked was served with 3 GB, no
cores and llama.cpp's prompt cache; on a busy node its judgments missed
their budget and fail: closed refused the requests. It now gets init's
2 GB and cores, and every judge modelserve serves runs with
--cache-ram 0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:32:04 +02:00
Ilker ManapandClaude Opus 5.5 99c34fd68f v0.14.9: changelog and README
Release / build (push) Successful in 5m51s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 6m24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 16:12:01 +02:00
Ilker ManapandClaude Opus 5.5 cfc38d35cc A node's console opens on what the machine is doing
CI / test (push) Successful in 5m12s
sudo llmgw on a node showed only agent.yaml, the manual, wipe and leave.
It now opens on an Overview read on the node itself: the agent's state and
version, the control planes it dials and whether they answer, each GPU with
the models on it, the engines from the supervisor's state files, the free
space for weights and engines, and the agent's warnings of the last hour.
No admin token is needed, every command is bounded by two seconds, and a
missing tool is said in a line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 12:55:27 +02:00
Ilker ManapandClaude Opus 5.5 ac25a9136a Sign-in is set up from the console, and a directory tried before it is kept
People → Sign-in shows single sign-on, Active Directory and SCIM, and
the directory's last sync. E sets up Entra ID from the tenant and client
IDs; D sets up Active Directory, tried from the control plane first:
each server bound, people who may sign in, members of each mapped group.
The secrets go into the store as store:auth/..., sealed by the master
key, and the CA into the settings, so no control plane needs a file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 12:55:27 +02:00
Ilker ManapandClaude Opus 5.5 4b6b3eeddb A node's page says which llmgw it runs and which model is on each GPU
CI / test (push) Successful in 4m13s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 11:31:52 +02:00
Ilker ManapandClaude Opus 5.5 178b168bfd A broken directory setting, or a second server, no longer takes the cluster down
CI / test (push) Successful in 4m15s
Found trying auth.ldap against a Samba DC on the PoC. A CA file a
control plane could not use made every control plane exit, and no
ctl config could then put the setting right: directory sign-in is now
turned off there, and said. `llmgw server check` ran a whole server
that, its ports taken, held the leader's lease for three hours: server
refuses words it does not know and takes its ports before anything else.
The guides now say auth is set with ctl config, not server.yaml.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 11:10:43 +02:00
Ilker ManapandClaude Opus 5.5 2005cea326 v0.14.8: changelog and README
CI / test (push) Successful in 9m12s
Release / build (push) Successful in 4m20s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 11:00:28 +02:00
Ilker ManapandClaude Opus 5.5 f27d722111 People sign in with their Active Directory account
auth.ldap checks a username and password against the domain controllers
over LDAPS or StartTLS: found with a service account, checked by binding
as the person. Groups, nested ones included, give staff roles and places
in the tree. Accounts are tied to the objectGUID; the leader reads the
directory every 15 minutes, suspends whom it disabled or removed and
reinstates them after, and refuses a sync that would suspend most.

Entra ID sends no groups for someone in more than 200, only where to
fetch them; that was read as none and took an administrator's role away.
It is now left as it was, and logged. An account made by SCIM or OIDC
can be reinstated again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-18 10:59:50 +02:00
Ilker ManapandClaude Opus 5.5 fe310576e7 Several keys for one outside provider
CI / test (push) Successful in 8m52s
An external model takes api_key_files, each a file and an optional weight,
in place of api_key_file. Each key is an endpoint of its own
(external:MODEL#1, #2), so the router's scoring, retries and breakers work
per key. A key answering 429 is passed over while its breaker is open. A
key answering 401 is passed over too, and external_key_refused is sent,
naming the endpoint and never the key; with a single key the 401 is still
the client's. A key whose file cannot be read is left out, and usage
records which key answered by its endpoint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 08:56:30 +02:00
Ilker ManapandClaude Opus 5.5 688b205604 What llmgw adds to a request, measured: ctl bench overhead
CI / test (push) Successful in 8m56s
An engine that answers at once and the gateway in front of it, in one
process on loopback, given the same load straight and through the gateway:
bare, with authentication and accounting, and with the guardrails' pattern
checks. It reports p50 and p99, what the gateway added, and a rough CPU
per thousand requests.

On an Apple M5 at 100 to 2,000 requests a second (10 s a step, 72,000
requests, none failed), the gateway added 0.05–0.42 ms at p50 and at most
0.49 ms at p99. That is now in the README, with how it was measured and
why the CPU figure is rough. CI runs a one-second version.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-17 00:23:26 +02:00
Ilker ManapandClaude Opus 5.5 35ccabac28 modelserve: a job's request is set under the lock Get reads it with
CI / test (push) Successful in 8m49s
CI run 394, on a commit that changed only BACKLOG.md, failed with a data
race in TestAVisionModelIsServedWithItsProjectorBesideIt. The serve job's
goroutine wrote j.Request without the lock (since efffdb6, which sized
embedding slots), while Get's snapshot read the job under it. Every other
write to a running job holds the lock. It did not reproduce in 30 runs
here; the race detector's trace names this write and that read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 22:40:09 +02:00
Ilker ManapandClaude Opus 5.5 2545fca174 An upgrade refits the judge an earlier server init installed
CI / test (push) Successful in 8m53s
A v0.14.6 install on fewer than eight cores refused every request until
its judge was given cores by hand, and upgrading did not change that. The
judge keeper, on the leader, now finds a judge whose entry is exactly what
init before v0.14.7 wrote: one llama.cpp CPU profile, an 8192 context and
nothing else, 2 or 4 GB, none to two cores. It gives the judge 2 GB,
--cache-ram 0 and cores by init's rule through modelsvc.SetProfiles, then
restarts its ready replicas. The replica count is left as it is. A judge
whose entry differs is left alone, and the log says so once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 22:06:56 +02:00
Ilker ManapandClaude Opus 5.5 b8274d6278 A restart is not refused for room another restart is giving back
CI / test (push) Successful in 8m51s
On a 4-core single-machine install, `ctl model resources` was run on the
judge (to two cores) and on the starter (to none), then both restarted.
The judge's restart was refused: its replacement fit on no node even with
its old replica stopped, because the starter still held its two cores. A
restart now waits for other restarts under way (queued, retiring or
draining) when their going would make room, up to DrainGrace.

A drained replica was also kept until its model had enough live replicas,
and a replica being restarted that still answers did not count. So with
replicas restarted one after another, each drained one waited out the
five-minute grace, holding its room.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 21:33:20 +02:00
Ilker ManapandClaude Opus 5.5 8164260e3b Backlog: test VMs removed, Apertus on demand
CI / test (push) Failing after 6m12s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 21:18:46 +02:00
Ilker ManapandClaude Opus 5.5 fbb5df05b8 Backlog: what is open, in order; J, what the HA and single-machine tests found
CI / test (push) Successful in 8m59s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 21:07:57 +02:00
Ilker ManapandClaude Opus 5.5 af942aca27 v0.14.7: changelog and README
Release / build (push) Successful in 5m33s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 10m30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 20:11:26 +02:00
Ilker ManapandClaude Opus 5.5 3e7dd20bad One machine runs one judge, on up to four cores
CI / test (push) Successful in 9m1s
A second judge replica on the same machine shares its cores and is lost
with it, so it bought nothing but half the speed of each. On a 4-core,
8 GB single-machine install, one judge on four cores passed 20 of 20
requests at about 3.5 s each, judged in and out. Two judges on two cores
each had passed 19 of 20.

server init now serves one judge when this machine is the only node, and
two, on two cores each and spread over the nodes, where there will be
others (HA setups, or a control plane that is not a node).
--judge-replicas and the setup's Copies still choose. install.md gives
the measured times in place of "half a second".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 19:35:42 +02:00
Ilker ManapandClaude Opus 5.5 f8206c3540 A judge reserves two cores and 2 GB, with no prompt cache
CI / test (push) Successful in 9m1s
Checked by a single-machine install of v0.14.6 on a 4-core, 8 GB VM. The
second judge fitted nowhere: v0.14.6 reserved 4 GB a judge, a figure from
one replica that grew to 3.2 GB through llama.cpp's prompt cache (up to
8 GB by default), which a judge never reuses. Once it fitted, every
judgment timed out. v0.14.6 gave a judge one core below 8 CPUs, and a
judgment of about 280 tokens takes 8.0 s on one core, 4.2 s on two and
2.3 s on four, against a 5 s budget: 0 of 20 requests went through.

A judge now runs with --cache-ram 0, uses about 1.6 GB and reserves 2,
and reserves two cores (one only below four CPUs). On the same VM, 19 of
20 went through.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 19:22:33 +02:00
Ilker ManapandClaude Opus 5.5 0def6eaba1 CI: run on maas-packer, the runner that serves this Gitea, given 8 cores and 32 GB
CI / test (push) Successful in 9m1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 18:15:58 +02:00
Ilker ManapandClaude Opus 5.5 645be2d278 CI: run again on the runner given 8 cores and 32 GB
CI / test (push) Canceled after 12s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 17:49:44 +02:00
Ilker ManapandClaude Opus 5.5 25c4ec9f2a CI: a package that hangs says which test it hung in
CI / test (push) Failing after 30m4s
Every test run since c22528c ended at the job's 30-minute deadline with
nothing after internal/release, while the same tests pass with -race on
Linux, as a user and as root, in 5 minutes. A 12-minute test timeout makes
the hang name itself.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 16:54:48 +02:00
Ilker ManapandClaude Opus 5.5 7eb50c5eb6 v0.14.6: changelog and README
CI / test (push) Failing after 30m6s
Release / build (push) Successful in 17m32s
Release / notes (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 13:33:13 +02:00
Ilker ManapandClaude Opus 5.5 b8fc1de822 Backlog I: what other gateways do that llmgw does not
CI / test (push) Failing after 30m6s
From a comparison with Bifrost: answers to similar questions, the
customer's MCP servers behind llmgw, the gateway's overhead measured, and
several keys for one outside provider. H9's /mcp item is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:29:20 +02:00
Ilker ManapandClaude Opus 5.5 63b2c6d9a4 The judges and the starter divide a machine's CPUs instead of each taking all
CI / test (push) Failing after 30m7s
Measured on the three-VM cluster with ha1's CPUs loaded: 38 of 60 requests
refused with both judges and the starter on ha1, each engine on all four
cores. With the judges moved to a 4-core, 32 GB node at 2 cores each, 2 of
60 were refused, both the first after the judges started.

init now reserves cores by this machine's count: one per judge replica
under 8 CPUs and two from 8, and the rest, up to 8, for the starter on CPU.
The starter reserves nothing when nothing is left, so it still fits. Judges
bound for nodes not yet known get two each. A judge reserves 4 GB; it uses
about 3.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 13:00:39 +02:00
Ilker ManapandClaude Opus 5.5 0b984a92bc A judge is asked again only on another machine
CI / test (push) Failing after 30m3s
Measured under CPU load on the three-VM cluster, with both judge replicas on
ha1: 37 of 60 requests failed with the hedge, 38 of 60 without it. Two
replicas on one saturated machine are slow together, and the second
question doubled the work there. The hedge now goes only to a replica whose
address names another host.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 10:27:50 +02:00
Ilker ManapandClaude Opus 5.5 bc4c5d5ac9 A judge replica that hangs is overtaken by another at half the budget
On the three-VM cluster about one answer in twenty-five came back as
content_filter: both judge replicas timed out within the same millisecond.
The tries shared the policy's 5 s budget, so the first replica, busy on an
oversubscribed machine, used it all and the retry on the second had nothing
left. A judge request now goes to a second replica too when the first has
not answered by half the budget, and the first answer is taken. The first
is not cut short.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 08:35:28 +02:00
Ilker ManapandClaude Opus 5.5 b09922b29b Leadership stays put while the store's primary is replaced
CI / test (push) Failing after 30m3s
Measured again after the store fixes. Cutting the store primary's power no
longer hung requests, but leadership moved anyway: the lease had lapsed for
everyone while Patroni promoted the standby, and a standby asked first. The
new leader then spent about 25 s without its nodes, and so without its
judges.

A standby that saw the store out of reach now leaves the lease alone for one
renew round after the store returns. The default lease is 10 s, and a node
detects a control plane that died silently in 8 s (5 s idle, 3 s ping)
rather than 15.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 23:17:58 +02:00
Ilker ManapandClaude Opus 5.5 5a4b022dcd A lost store machine is given up on in seconds, and a new leader waits for its judge
CI / test (push) Failing after 30m6s
Measured on three VMs: cutting the store primary's power cost about 45 s of
hanging requests. New connections tried the dead member first with no
connect timeout, and Patroni kept its 30 s leader key. Cutting the leader of
both cost a further 17 s of 503s while the new leader knew no judge.

The store dials each member for 3 s and closes connections to a machine that
is gone within about 10 s (keepalive, TCP_USER_TIMEOUT). Patroni runs with
ttl 20, loop_wait 5 and retry_timeout 5, applied in place to existing
clusters through its API. Judges that all fail at once are asked again
every 250 ms while the policy's budget lasts, and reported once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 20:33:13 +02:00
Ilker ManapandClaude Opus 5.5 eeaffd764d A floating address keeps to its own network, and Caddy follows the leader
CI / test (push) Failing after 30m3s
Found by a three-machine install on fresh VMs. The second standby's run
asked for the floating address's interface with `ip route get <vip>`.
The machine already held the address, so that answered lo, and keepalived
would not start. The interface now comes from the route to the address's
network. Every cluster's VRRP router id was 51, so the PoC's two control
planes and the new three on one LAN each logged the other's adverts as an
invalid password; the id now comes from the address.

The Caddyfile, deploy/ha and install.md send clients to the control plane
whose /leader is 200, not /ready: /ready is 503 on a leader with no model
yet and would take the portal away with it. Setup no longer hides a key
file's path as if it were a password, and the starter's text no longer
says it runs elsewhere when this machine is a node.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 17:35:10 +02:00
Ilker ManapandClaude Opus 5.5 94401e21e6 Setup lets every part be chosen, each with its settings; three machines each run the gateway
CI / test (push) Failing after 30m4s
The setup screens list the parts of an install: control plane, store, model
mirror, this machine as a node, starter model, pictures and 3D, the judge,
firewall, notifications, limits, on-demand turns and an offline bundle. Each
says what it is or what is missing without it. Space turns a part on or off;
Enter opens its settings in a dialog that explains it. A plan page lists what
is installed and what is left out, and the same install as a command. Every
setting is a server init flag too, written into server.yaml when init writes
it.

With three machines, the two database machines become control planes too,
standing by (server add's steps), with an optional floating address. The
mirror's management API answers all three control planes, so a standby that
comes to lead can still fetch models.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 11:22:36 +02:00
Ilker Manap 733e20924c Merge remote-tracking branch 'origin/mcp-wait' into sd-dedupe
CI / test (push) Failing after 30m3s
2026-10-15 07:53:22 +02:00
Ilker ManapandClaude Opus 5.5 fc60481e40 Every control plane lists the others alike to Prometheus
140 named 150 a store member, 150 named itself a control plane: Prometheus
asking both scraped 150 twice and counted its every series twice, and asked
of 140 alone while 150 led it lost every node. Each control plane now lists
all of them as control planes before the store's members, so two answers
agree and a target is scraped once. The shipped prometheus.yml says to
scrape and ask every control plane.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 07:53:22 +02:00
Ilker ManapandClaude Opus 5.5 edeeb9f8e1 MCP takes pictures of real size, by id too, and waits for a cold model
The game's bridge found three things on the PoC. /mcp held its body to
64 KiB — the edit meant for it had raised the memory notes' limit instead —
so a 1024 px concept sent as base64 failed as "expected one JSON-RPC
request object". The notes are back to 64 KiB, /mcp takes 64 MB, and a
call past it is a 413 saying so.

/v1/files took batch files only, so a picture could not be sent once and
named after. purpose vision and user_data are kept too, as long as job
results, and generate_mesh hands a workflow model the id as it is.

generate_image and review_images failed at once while their model was
cold. They wait out an answer that says when to come back — loading,
another model holding the GPU, a full queue — for as long as the call may,
saying who holds the GPU and about how long; a refusal that says no
Retry-After, a spent budget, is said at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:24:59 +02:00
Ilker ManapandClaude Opus 5.5 c22528c26e A model that does not fit its card is not added to fail; weights in use are not removed
CI / test (push) Failing after 30m3s
Apertus 70B with a 65536-token pool, and then 8B with 131072, on a 24 GB
card loaded their weights and failed to make their context on every start,
the 8B taking the card from the 70B each time only to fail. ctl model serve
now shrinks a pool the card cannot hold beside the weights (KV cache from
the GGUF's shape, 90% of the card, a gigabyte for the engine), saying so,
and refuses weights that do not fit at all; the portal's serve form refuses
such a pool and says the largest that fits.

The mirror page removed the 70B's weights while the catalog still had the
model, whose replicas went on loading the node's copy with nothing said.
Removing weights a catalog model loads is refused, naming the model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:22:01 +02:00
Ilker Manap bda988b78f Merge remote-tracking branch 'origin/setup-webhooks' into shared-replica
CI / test (push) Successful in 9m54s
# Conflicts:
#	CHANGELOG.md
2026-10-14 14:26:27 +02:00
Ilker Manap a7cb3aac3c Merge remote-tracking branch 'origin/mcp-media' into shared-replica
# Conflicts:
#	CHANGELOG.md
2026-10-14 14:01:24 +02:00
Ilker ManapandClaude Opus 5.5 d1e91868ee Setup asks where alerts go, and sends each a test
notify.webhooks existed and setup never asked for one, so a new site
learnt of a parked model or a lost node from its users. `llmgw server
init --webhook URL` (repeatable) and a Notification webhooks field on
both setup screens take them now. Slack's address, Mattermost's and
Rocket.Chat's /hooks/ and Discord's …/slack are sent Slack's text, any
other the event as JSON; slack: or json: in front says which.

Once the control plane runs, they are added to the cluster's settings
beside the webhooks already there, none twice, and setup waits for the
control planes to start again on the change before sending each a test,
saying which answered. A webhook's path is its secret, so it is shown by
host only. A failure is said, not fatal: the cluster serves without its
alerts, and ctl notify test tries again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:55:48 +02:00
Ilker ManapandClaude Opus 5.5 aa531b1053 ComfyUI models of one environment share one replica
flux and sf3d on one card handed it to each other, each starting ComfyUI
and loading its weights from nothing (BACKLOG H3, placement side). An
environment of two or more models now gets a hidden catalog entry,
comfyui-env-<name>: on its first member's profiles with the most memory
any member needs, on demand for the longest idle timeout, carrying every
member's weights, repositories and the environment's custom nodes. The
members get no replicas of their own; asking for one wants the entry.

The agent fetches each member's weights; the gate links all of them,
refusing two weights files of one name, links each member's Hugging Face
repository, and answers /v1/models for every member. The control plane
offers the replica under the members' names with each one's workflow and
checkpoint, which the router sends by the model asked for. A member's
sibling never evicts the replica serving it, and the members' load counts
as the entry's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 13:55:34 +02:00
Ilker ManapandClaude Opus 5.5 55d5e13534 Pictures, 3D models, a vision model and workflows as tools at /mcp
An agent driving Blender made its concepts, meshes and render reviews
through a tool server of the game's own, which called this gateway; the
gateway's MCP server offered only the memory notes. It now offers
generate_image, generate_mesh, review_images, run_workflow, job_status and
gateway_status beside them, each only where a model for it is here.

Each tool makes its request of the client API as the caller, through the
handler Handler built, key check and all: allowlists, limits, licences
left out, budgets and accounting apply as to any request, and a job is
the caller's. What a tool makes is kept as the caller's file and answered
by id and download URL, since a server over HTTP cannot write to the
caller's disk; a picture comes with a 512 px preview, and whole where no
file can be kept. A picture to work from is a file id, a data URL or
base64; one sent to the vision model is shrunk to 1280 px.

A mesh takes minutes. A client that takes a stream is answered as
server-sent events, with notifications/progress for its token while the
job is queued, waiting for the GPU and running, and waits up to ten
minutes; one that does not waits 50 seconds and gets the job id, which
job_status asks after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:11:09 +02:00
Ilker ManapandClaude Opus 5.5 7c53380e68 Extended thinking on /v1/messages: a model's reasoning as thinking blocks
CI / test (push) Successful in 9m12s
Claude Code and the Anthropic SDKs asking for extended thinking got the
answer alone: a reasoning model's reasoning_content was dropped. Asked for,
it comes back as a thinking block before the text — streamed as
thinking_delta events and closed with a signature before the text begins —
and the model is told to think through its chat template
(enable_thinking); disabled tells it not to. Not asked for, it is left out
as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 11:05:02 +02:00
Ilker ManapandClaude Opus 5.5 0460f52dd2 Jobs run side by side, one per model at a time
CI / test (push) Successful in 10m28s
One job at a time, a 3D job waiting for the card held up another model's
job that could have run (seen on the PoC, BACKLOG H7). The leader's worker
claims jobs while fewer than eight run, passing over the models that have
one running, and starts each in its own goroutine; one ending wakes it for
the next. Lease and leader-only rules are as they were.

BACKLOG: H5–H7 marked, H8 (presets) and H9 (what the PoC taught) written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 10:06:20 +02:00
Ilker ManapandClaude Opus 5.5 efffdb66b7 An embedding model is served for embedding, with many slots
ctl model serve served one as a chat model: no --embedding, four slots, a
512-token batch, embeddings unset, and a chat smoke test it could not
pass. A GGUF that pools (<arch>.pooling_type), or --embeddings, gets
--embedding, 32 slots of 1024 tokens unless sized otherwise, a batch that
holds a whole passage, the embeddings capability, and one vector asked for
as its smoke test (canaries and updates too). An import of a hundred
thousand chunks had gone at the pace of four slots with the card a third
busy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 09:19:57 +02:00
Ilker ManapandClaude Opus 5.5 f5eb6963af An upgrade that leaves nodes behind says which
A site's nodes stayed a release behind after its control plane was
upgraded, with nothing printed to say so. ctl upgrade checks every node's
version once the waves are done, fails naming any not on the release, and
says when the control plane has no nodes registered. llmgw upgrade on a
control plane, which looks like the cluster's upgrade and is not, says how
many nodes it left behind and the command that upgrades them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 09:01:09 +02:00
Ilker ManapandClaude Opus 5.5 1e7086e13e How long an on-demand model holds a card is a live setting
On one card shared by flux and sf3d, a mesh asked for after a picture
waited out the picture model's five minutes of protection and two of idle.
on_demand.evict_after_idle and on_demand.min_lifetime set those for the
cluster, in force on the leader as they change; unset keeps 2m and 5m.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:58:38 +02:00
Ilker ManapandClaude Opus 5.5 a9a2360f44 pages.js is loaded by its versioned address
CI / test (push) Successful in 11m15s
Five pages named /_static/pages.js outright, cached for a day, so a
browser kept the old script after an upgrade — the one holding refreshes
off while a form is filled in among them. They take it through asset, as
the rest do, whose address changes with its contents.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:15:44 +02:00
Ilker ManapandClaude Opus 5.5 e78075876b A form being filled in is not refreshed away
CI / test (push) Failing after 7m30s
The cluster and node pages swap their live part every few seconds, and the
forms inside it went with it: what was typed, an open choice, the focus.
Filling one in raced the clock. The swap now waits while a field in the
live part has the focus or a form in it has been typed into and not sent;
sending it, or leaving the page, ends that. The mirror page's reload
during a download goes through the same check instead of http-equiv.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:13:08 +02:00
Ilker ManapandClaude Opus 5.5 45666e72db v0.14.5: changelog and README
CI / test (push) Successful in 11m27s
Release / notes (push) Successful in 4s
Release / build (push) Successful in 5m42s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 01:33:59 +02:00
Ilker ManapandClaude Opus 5.5 2eb2a6d8fe A mesh carries its whole licence, not only its name
CI / test (push) Failing after 6m25s
GLBs carried the licence's name, URL and notice in asset.extras.license.
A pipeline that sorts assets by licence needs what a machine matches on:
the id, whether commercial use is allowed, the regions it excludes, the
revenue cap and the attribution. The whole licence is written, as
/v1/models gives it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 01:12:47 +02:00
Ilker ManapandClaude Opus 5.5 cfc636e328 A mesh's base colour can be brought to the picture it was made from
CI / test (push) Failing after 6m28s
Stable Fast 3D takes the light out of the picture it sees, and a dark
basalt (median 0.17) came back a light stone dimly lit (0.67). The save
node takes albedo_match, the picture and its mask, and scales the base
colour texture in linear light by the ratio of the object's medians, so
marks keep their contrast; texels nothing maps to are left alone. On the
game team's rock it is 0.18.

A workflow's {{name|false}} default now fills a BOOLEAN input with a
boolean rather than the word.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:51:06 +02:00
Ilker ManapandClaude Opus 5.5 848db08469 The cluster page and the console say which picture and 3D models are here, and add them
CI / test (push) Failing after 6m27s
A site whose catalog had no flux-schnell or triposr showed no Images or 3D
page, and nothing said they were one click away. The cluster page now has
a Pictures and 3D models card under the models: each preset, what it
makes, the classes of card here it fits, whether and by whom it was
added, and why the leader has not added it by itself; an Add button, which
says first when it would install ComfyUI on the GPU nodes and restart the
control planes; and the switch for presets.auto. The console's models
screen shows the same under its table: A adds one, asking first in the
same case, and P turns automatic adding on or off.

`ctl model add PRESET` and `ctl model presets` go through the presets API,
so the control plane keeps who added one, and `ctl model presets auto
on|off` flips the setting; against a control plane from before, add sends
the entry as v0.14.4 did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:48:40 +02:00
Ilker ManapandClaude Opus 5.5 f42af27da5 The leader adds the picture and 3D presets a node fits, unless told not to
A customer's portal had no Images or 3D pages: nobody had written a
definition for either, on a cluster whose V100 runs both. The leading
control plane now adds flux-schnell and triposr, on demand, once a node
has a card that fits one, the cluster installs ComfyUI, and the catalog
makes nothing of that kind. presets.auto (a live setting, on by default)
turns it off; a preset somebody removes is recorded as declined and not
added back until added again by hand. Each addition is in the audit log.

GET /api/v1/presets says how each preset stands, the ComfyUI the cluster
installs, and why one is not added; POST /api/v1/presets/{name} adds one
(setting engines.comfyui where there is none); PUT /api/v1/presets/auto
switches it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 20:48:40 +02:00
Ilker ManapandClaude Opus 5.5 d817558121 v0.14.4: changelog and README
Release / build (push) Successful in 2m4s
CI / test (push) Successful in 9m28s
Release / notes (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 10:37:05 +02:00
Ilker ManapandClaude Opus 5.5 295186a318 CHANGELOG: presets, ComfyUI on a V100, the open driver's version
CI / test (push) Successful in 10m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:21:51 +02:00
Ilker ManapandClaude Opus 5.5 40b8948d7d A picture or 3D model is added by its name: llmgw ctl model add triposr
flux-schnell and triposr come with llmgw as presets: their workflow,
custom nodes, packages and licence as they were found and tried on the
PoC, and a profile that says the card it needs rather than a class, so the
entry places on any site's cards. TripoSR's workflow is built in, with the
LLMGWPrepareForMesh node that keeps a picture's background out of the
mesh, which the copy in the document had left out.

`ctl model add PRESET` adds one, and has the nodes install ComfyUI when
the cluster's settings name none; `ctl model presets` lists them; the
console's add-model dialog offers them beside a definition file. A
ComfyUI build is now also checked to keep its order on a Turing card, so
a node's installed cu13 is not replaced by the cu126 build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:21:28 +02:00
Ilker ManapandClaude Opus 5.5 52898fbb80 A V100 gets a ComfyUI it can run, and an open driver is read by its version
PyTorch's CUDA 12.8 and 12.9 builds start at Turing since torch 2.8, as
CUDA 13 does, so on a V100 both ComfyUI builds were installed, 10 GB each,
and failed on the card with "no kernel image". The cu129 build now needs
7.5, and a third, on PyTorch's CUDA 12.6 torch, carries Volta and Pascal.

The open kernel module, NVIDIA's default since 560, writes "Open Kernel
Module for x86_64  580.82.07" in /proc/driver/nvidia/version, and the word
after "Module" was taken as the driver: "for". With the driver unknown,
every build was tried whatever driver it needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:21:27 +02:00
Ilker ManapandClaude Opus 5.5 7781b6a308 CHANGELOG: the gated repository a node loads by name
CI / test (push) Successful in 12m24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 10:20:07 +02:00
Ilker ManapandClaude Opus 5.5 fa6e68c8e8 A model put first is not evicted, idle, for a lower class
CI / test (push) Failing after 7m59s
The VLM is to come first on the one card. Its class makes it take the card
from flux, triposr or sf3d when asked for, but idle eviction ignored class:
a 3D job two minutes into a lull would have taken the card from it. Idle
eviction offers only replicas of no higher class than the model wanting
room, and the wait such a model is told runs to the holder's idle timeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 10:15:14 +02:00
Ilker ManapandClaude Opus 5.5 967df77e53 The agent hands the gate the repository a node loads its weights by
CI / test (push) Failing after 6m22s
sf3d's first jobs failed with a 401 from stabilityai/stable-fast-3d. The
weights were on the node, and the gate links them into the Hugging Face
cache when told the repository — but the agent passed on only the custom
nodes, so nothing was linked and the node went to the network for a gated
repository.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 23:28:22 +02:00
Ilker ManapandClaude Opus 5.5 3c626cdc96 A model removed from the catalog is no longer wanted
CI / test (push) Successful in 10m18s
Removed and added again to give it a licence, qwen3-vl-8b came back loading
by itself: its name was still on the wanted list from before. A model that
leaves the catalog leaves the list too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 20:50:57 +02:00
Ilker ManapandClaude Opus 5.5 3858114ada A picture in a prompt counts as what a model makes of it, not its base64
CI / test (push) Successful in 10m43s
A 1280x720 PNG sent to qwen3-vl-8b was estimated at 138 thousand prompt
tokens and refused against its 8k context before reaching the engine, which
makes about a thousand of it. Parts are counted by kind: text by its text,
a picture or a sound as a thousand tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 20:45:06 +02:00
Ilker ManapandClaude Opus 5.5 ac4360b3fd A custom node's compiled packages are built for the card that runs them
CI / test (push) Failing after 6m23s
Stable Fast 3D's texture_baker and uv_unwrapper are C++/CUDA packages no
index has; on the PoC they were built and installed into ComfyUI's
environment by hand. nodes[].build names such directories, and the gate
builds each as a wheel with ComfyUI's Python, for the compute capability
torch reports for the replica's own card, keeps it on the node by the
node's commit, the architecture and the directory, and installs it
beside the replica with the node's requirements. A copy in ComfyUI's
environment does not pin it. A node with no CUDA toolkit says so.

comfyui.huggingface_repo shows the replica's weights to a node that
loads them by repository name, in the engines' Hugging Face cache, so a
gated repository needs no token on the node.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 17:23:05 +02:00
Ilker ManapandClaude Opus 5.5 587d8d5059 A licence left out by its name stays out once its entry gives an id
CI / test (push) Failing after 6m30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 16:28:40 +02:00
Ilker ManapandClaude Opus 5.5 4bbac0833d A person leaves licences out, and no key of theirs reaches a model under one
CI / test (push) Successful in 11m28s
A game sold in the EU cannot ship what a licence excluding the EU made. On
the profile page a person leaves out licences one by one, or by rule:
every licence forbidding commercial use, every licence granting nothing in
a region. The rules cover models added later. The choice reaches every
principal through the resolved limits, so permits() leaves such models out
of /v1/models, requests, jobs, fallbacks and the portal's pickers, and a
request for one is refused 403 licence_excluded saying which choice did.

Kept in user_excluded_licences (migration 0046), copied with the store and
gone with the person.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 16:10:22 +02:00
Ilker ManapandClaude Opus 5.5 a313b3e0da The reference documents a profile's gpu need and its own comfyui block
CI / test (push) Failing after 8m7s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 16:10:16 +02:00
Ilker ManapandClaude Opus 5.5 777a9223a7 A profile may name the card it needs rather than its class, and fit any site
CI / test (push) Failing after 6m19s
A catalog entry meant for many sites cannot know their GPU class names:
the PoC has a Quadro RTX 6000, a customer an A100, another an L40S. A
profile may now say gpu: {min_compute_capability, min_memory_gb} in
place of gpu_class, and stands for every class in the cluster that meets
it, named <profile>@<class>, at its own priority; placement still sees
exact classes. ProfileByName and EngineProfile resolve such a name to
the profile on that class, so every caller that maps a replica back to
its profile works unchanged. A ComfyUI profile may carry its own
workflow, checkpoint and nodes, and with its own source an fp8 workflow
and weights go to cards with fp8 and fp16 ones to Turing.

A profile by need is left out of placement until the cluster's cards are
known: with no class it would have read as a CPU profile.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 16:02:32 +02:00
Ilker ManapandClaude Opus 5.5 b6bfc33ef7 Any ComfyUI workflow runs as a job, from the inputs its catalog entry declares
CI / test (push) Successful in 9m7s
A model's catalog entry now says what its workflow takes and makes:
comfyui.inputs, each a placeholder of its name with a type (text, int,
float, bool, choice, image, mask, audio, video, file), bounds, choices
and a default; and comfyui.output, which gains video, audio and files
beside pictures and meshes. POST /v1/workflows/runs with {model, inputs}
checks a request against them before a job is written — a required input
left out, an unknown one, a number out of bounds, a song sent as a
picture, another owner's file id are each refused with what was wrong —
and the job worker replays it to a replica, where the files go into
ComfyUI's input directory, the workflow is filled and run, and the first
file of the model's output kind is the job's result. /v1/models lists
each ComfyUI model's workflow, so a client can build its form.

A PNG result carries the licence in an iTXt chunk, as a GLB does in
asset.extras. A file whose server named no type is typed by its name
before its bytes, so a WAV is audio/wav, not audio/wave.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 14:28:15 +02:00
Ilker ManapandClaude Opus 5.5 b43e66c95e ComfyUI models that agree on their nodes share an environment; a licence says what a machine can match
CI / test (push) Failing after 6m21s
A ComfyUI model may name an environment: the models of one environment
agree on every custom node they both name and on every package range,
so one replica can carry all of them and ComfyUI moves between their
weights itself rather than the card being handed over with a cold start
each time. The catalog refuses an environment that cannot share a
Python, naming both models; Catalog.Environments gives each one's
models, the union of their nodes and their weight sources, for
placement to build on.

A licence gains an id (SPDX where there is one), whether its output may
be used commercially, the regions it excludes, a revenue cap and the
attribution it asks for, so a person's or a site's choice of licences
can be matched without reading prose.

BACKLOG gains section H: ComfyUI as a platform.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 09:19:50 +02:00
Ilker ManapandClaude Opus 5.5 0639edfd3b An adopted on-demand replica counts as used when adopted
CI / test (push) Failing after 6m25s
After a control plane restart flux, adopted and asked for by nobody, had no
last use and so was never idle enough to evict: a 3D job waited out its
whole fifteen minutes with no estimate. It counts as used at adoption, as it
is already counted as wanted then; a replica in use is reported by the
router within seconds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 08:55:33 +02:00
Ilker ManapandClaude Opus 5.5 95c61daf1b Jobs are served by the leader, and a moment of fitting nowhere is waited out
CI / test (push) Failing after 8m47s
A 3D job leased by the standby control plane, whose router has no endpoints
and which a job replayed in the process never leaves, was told "loading"
for seven minutes while the leader served the jobs behind it. Jobs and
batches are claimed only while leading.

Five jobs asked for while cuda01's agent restarted failed for good as
"cannot be placed": re-registering, the node had no engines for a few
seconds. A job asks again for five minutes before that is its end.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 23:53:33 +02:00
Ilker ManapandClaude Opus 5.5 9b8ec89247 A mesh node's result is a file the gate can fetch
CI / test (push) Successful in 8m59s
Stable Fast 3D's own save node hands back the GLB as base64 in its UI
output, which the gate does not read. LLMGWSaveMesh writes each mesh to
output/ as a GLB and names it, as a picture's save node does. The
prepare node also gives the object's mask now, which SF3D's sampler
takes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 22:29:34 +02:00
Ilker ManapandClaude Opus 5.5 b1490dbf30 A job waiting for the card says what holds it and about how long
CI / test (push) Failing after 6m16s
A 3D job waiting for the picture model to give up the one card had an empty
progress; its client could not tell waiting from stuck. The control plane
says which model holds the cards and when it lets go, plus the waiting
model's last start; the 503 carries it as headers, and a job keeps it as
progress {state: waiting_gpu, held_by, eta_seconds}.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 22:25:27 +02:00
Ilker ManapandClaude Opus 5.5 cf2edfd995 A model evicted for being idle is no longer wanted
CI / test (push) Successful in 8m58s
flux evicted to make room for triposr was placed again two seconds later:
still on the wanted list, and first in the catalog, it took back the card
it had just given up, and the 3D job went on waiting. Its last replica
evicted, it leaves the list until it is asked for again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 21:11:40 +02:00
Ilker ManapandClaude Opus 5.5 d0f7b1fc4f A model waiting for an on-demand one to step aside is waiting, not unplaceable
A 3D job asked for straight after a picture, on a cluster with one card,
failed at once with "cannot be placed": flux held the card, too freshly
started to evict. The cards coming back when an on-demand model goes idle
makes it a wait, so the request is told to retry and the job waits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 20:59:59 +02:00
Ilker ManapandClaude Opus 5.5 b743b11c54 A picture is made ready for a 3D model; a model asleep is listed asleep
LLMGWPrepareForMesh, llmgw's own ComfyUI node, written by the gate into
every replica's custom_nodes from the binary: TripoSR's own preparation
(tsr/utils.py) — the background taken away, by the picture's alpha where
it has one and by rembg (MIT; u2net, Apache 2.0) where it does not, the
object cropped to its bounds, set square at a ratio of the side (0.85),
on grey. A picture sent as it was had a white or black shell round the
object, whatever was behind it made into the mesh.

/v1/models listed only models with a replica up, so one that loads when
asked — triposr, flux-schnell — was invisible to the clients that would
have asked for it. Those are listed now, with loaded: false; every entry
says loaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 20:43:20 +02:00
Ilker ManapandClaude Opus 5.5 fd3c7bda32 The client API described in OpenAPI, and both APIs in Swagger UI
CI / test (push) Failing after 6m12s
/v1/openapi.json is the API clients use — OpenAI's endpoints, Anthropic's,
and what llmgw adds — in OpenAPI 3.1, generated from internal/clientapi's
route table by the generator the admin API's document comes from, now
told each document's title, server, token and error. A test reads the
router's mux.HandleFunc calls and refuses a route described and not
mounted, or mounted and not described. Asked for before there is a key,
so it needs none.

/docs/api shows it in Swagger UI 5.33.1 (Apache 2.0, served from the
gateway as the page's policy requires): Authorize takes a key, Try it out
sends to this gateway. Staff are offered the admin API's document too,
from /docs/api/admin.json, which nobody else is given.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 20:22:48 +02:00
Ilker ManapandClaude Opus 5.5 41601bc5ef The plan's phase 5 says what is built, what is built but unproven, and what is not
CI / test (push) Failing after 6m22s
The precise KV index and vLLM's LoRA affinity were built in v0.11.2 and
never run against a real vLLM; facility-aware placement in v0.11.2 less
temperature and cooling: marked [~], a mark now explained at the top of
the roadmap. Control-plane HA is done, by leader election on PostgreSQL
rather than Raft, and the admin UI changes things since staff roles
(v0.11.2) and the settings page (v0.14.1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:35:15 +02:00
Ilker ManapandClaude Opus 5.5 cf41adbf25 v1.0.0 in the roadmap is what phase 5 has left, not what it has done
CI / test (push) Failing after 8m29s
Tenancy, prefix affinity and benchmarking were built in v0.9 and after.
What phase 5 of llmgw-plan.md has open is precise KV routing proven on a
real vLLM, the block cost picker, defragmentation across NVLink domains
and prefill/decode disaggregation. Control-plane HA by Raft is left out:
HA is there already, by leader election on PostgreSQL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:33:10 +02:00
Ilker ManapandClaude Opus 5.5 21239a8488 README: v0.4.0 went out inside v0.5.0, and v0.14.2 and v0.14.3 are listed
CI / test (push) Failing after 8m7s
The table said v0.4.0 was released, which has no tag of its own; the
changelog's table says, rightly, that it was released as part of v0.5.0.
The list of what came since v0.11.0 stopped at v0.14.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:32:25 +02:00
Ilker ManapandClaude Opus 5.5 7f320c5f65 v0.14.3: changelog, README, docs for 3D models; a finished job says done
Release / notes (push) Successful in 5s
CI / test (push) Failing after 6m50s
Release / build (push) Successful in 2m6s
A finished job kept the last word heard from ComfyUI, the node running;
it is now said done before the job is marked completed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:22:58 +02:00
Ilker ManapandClaude Opus 5.5 2f54780802 G4: a page that makes 3D models, and ctl 3d
/3d beside /images, while some model makes meshes: a picture chosen, the
job asked for through the same path an API client's takes, its progress
shown (waiting for the card, n ahead; making it, the node and percent),
and the mesh in a viewer — model-viewer 4.4.0 (Apache 2.0, with three.js
and lit), served from the gateway since the page's policy loads scripts
from nowhere else — with a download and the model's licence. The mesh is
served to its owner from /3d/jobs/{id}/file; another project's person is
told there is no such job.

A job made from the portal acted for a key that does not exist: the
worker now rebuilds a session's principal from the account that made it,
while it is active and still in the project the job is charged to.

ctl 3d list|get|cancel, over /api/v1/client-jobs (/api/v1/jobs is the
control plane's own work, which ctl jobs shows).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:16:20 +02:00
Ilker ManapandClaude Opus 5.5 dfc2f0bf15 A custom node's own range decides the packages it names
TripoSR's checkpoint loads with transformers 4, and ComfyUI runs 5: of a
package a node names, its range decides, and of everything else what
ComfyUI runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:54:15 +02:00
Ilker ManapandClaude Opus 5.5 4b68328e3e A mesh's resolution is the model's own: a multiple of 32 from 64 to 2048
TripoSR takes 256 by default; 512, 1024 and 1536 were TRELLIS.2's alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:47:52 +02:00
Ilker ManapandClaude Opus 5.5 88781cfcfe A job says how far along it is; a ComfyUI model brings its custom nodes
G2: the gate keeps a websocket open to its ComfyUI under a client id of its
own, puts that id on every workflow it lets through — ComfyUI tells only
the client that queued a workflow how it goes — and keeps the last word on
each: the node running, its step of how many. It answers at
/progress/{prompt_id}. While a job's request waits, the router reads the
queue (queued, n ahead) and the gate (running, node, step, percent) and
writes it into the job, which a client reads back. Only into a job of the
same key: a tag naming another's job writes nothing.

G3, the rest of the plumbing: comfyui.nodes names custom nodes by https
repository and full commit, with the PyPI packages they need. The gate puts
each in the replica's own custom_nodes at that commit and installs the
packages beside the replica, constrained to the versions ComfyUI's
environment already has, which it never changes. The catalog refuses a
branch for a commit and anything but package specs for requirements. An
OBJ a node saves leaves the gateway as a GLB, colours kept, so every mesh
can carry its licence.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:46:44 +02:00
Ilker ManapandClaude Opus 5.5 c87e413360 A ComfyUI model may make meshes: from a picture, with its licence inside
G3, the part that needs no model: a ComfyUI model's comfyui.output (or
external.output) says pictures or meshes. A request for a 3D model — a
job at /v1/3d/generations — carries the picture as base64; at the replica
it is uploaded to ComfyUI's input directory (the gate lets /upload/image
through), the workflow is filled in with its name, the seed and the
resolution, and the mesh the workflow saves comes back with its type,
which is the file the job keeps. comfy.Client reads every list of files an
output names, not "images" only, and types a file by its extension where
ComfyUI sends octet-stream.

A model's license (name, url, notice) is said in /v1/models and in the
job, and written into a GLB's asset.extras, since the file travels without
the API; a mesh it cannot be written into is refused. per_mesh in
auth.pricing, usage.meshes in the rows, the sums and the export
(migration 0045). A picture asked of a mesh model, or a mesh of a picture
model, is refused saying which it makes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:30:52 +02:00
Ilker ManapandClaude Opus 5.5 ec233d3586 Merge admission-bulk into jobs-3d
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 17:35:39 +02:00
Ilker ManapandClaude Opus 5.5 63198f2753 A profile may load weights of its own
The catalog had one source a model, so an embedding model served by vLLM
from its repository on a card and by llama.cpp from a GGUF on CPUs had its
entry swapped by hand halfway through the metallurgy import. A profile's
source is what its engine loads and placement looks for; the model's is
the default, and what updates and canaries move. ctl model add checks
every source, the GGUF check reads each llama.cpp profile's, and ctl
weights collects none a profile loads. An empty source leaves a profile's
fingerprint as it was, so running replicas do not read as stale.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 17:15:38 +02:00
Ilker ManapandClaude Opus 5.5 497e4b24a1 A band may wait longer than the rest; a busy refusal says when to return
A knowledge-base import sends far more than the cluster can take at once,
and every band waited the same 30 s. A band now has its own queue_ttl, so
one for bulk work waits behind people instead of being turned away.

And a request turned away because the cluster stayed busy — the queue
full (503) or waited as long as its band may (504) — now carries
Retry-After: 10. Without it llmgw-kb, which retries only what says when
to, counted each such passage as failed; that is what turned parts of the
metallurgy import away.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 16:11:09 +02:00
Ilker ManapandClaude Fable 5.1 b51cc3cf92 A 3D model is a job: queued with an id, asked after, its result a file
POST /v1/3d/generations answers 202 with a job; GET says its status,
progress and result file; cancel stops it in the engine's queue too. The
request is kept as sent and replayed by whichever gateway holds the lease
through the path a client's request takes, so it is charged and judged
like any request (tagged job=<id>), and a model still loading is waited
out for as long as the job has. Migration 0044. Nothing makes 3D models
yet; the door refuses every model until the ComfyUI adapter learns meshes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 16:04:11 +02:00
Ilker ManapandClaude Fable 5.1 baaaa4e019 The game is sold in the EU, so Hunyuan3D is out and TRELLIS.2 is first
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:05:53 +02:00
Ilker ManapandClaude Fable 5.1 cb9149bce3 A mesh carries the licence it was made under
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:01:20 +02:00
Ilker ManapandClaude Fable 5.1 5ed18cc4c4 A 3D model is a job, asked after, not a request held open
Section G of the backlog: a jobs table of its own with a leased worker,
progress read from ComfyUI's websocket by the gate, the ComfyUI adapter
widened from pictures to meshes with custom-node installs, and the pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:57:13 +02:00
Ilker ManapandClaude Opus 5.5 9b7ceabd5b A store moved to PostgreSQL leaves notify_sent behind
When notifications were last sent is not worth carrying: at worst one is
sent again an hour early.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 14:48:20 +02:00
Ilker ManapandClaude Opus 5.5 39bb8ee557 Two control planes send a notification once
Each kept its own hour of quiet per event, so a budget spent on a team
both control planes served was announced by each. An event is now claimed
in the store before it is sent (notify_sent, migration 0043; one statement,
so two asking at once cannot both win), and only the claimer sends it. A
store that cannot be asked sends it anyway.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 13:36:08 +02:00
Ilker ManapandClaude Opus 5.5 8d7b96621e A sign-in through the identity provider may end on either control plane
The cookie that carries a sign-in across the provider was signed with a
key each process made for itself, so with two control planes behind a
balancer a callback reaching the other one was refused as tampered — half
of all sign-ins. The key is now the store's oidc-state secret, sealed like
the others and the same on every control plane.

Changelog: the fixes since v0.14.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 11:33:41 +02:00
Ilker ManapandClaude Opus 5.5 eaea671f23 The usage sums and exports count pictures
The five-minute, hourly and daily sums gain images (migration 0042), and
the CSV and JSON exports a column for them: once request rows were past
their retention, an export said what pictures cost but not how many there
were. The pictures made before are added to the sums from their rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:36:19 +02:00
Ilker ManapandClaude Opus 5.5 bb40d22eb8 An engine's output goes to a file, which an agent restart cannot break
Engines wrote to a pipe of the agent's. An agent that restarted and
adopted them left that pipe with no reader, and an engine's next write
past its buffer failed with EPIPE — in a Python engine, inside whatever it
was reporting on. Each process now writes to its own file in the agent's
work directory (0600), followed into the shard's log; an adopted engine's
file is read from the start, so its output from before the restart is in
its log too. A file past 64 MB is emptied in place; the files go with the
shard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:25:04 +02:00
Ilker ManapandClaude Opus 5.5 9e7618c073 v0.14.2: changelog and README
Release / build (push) Successful in 2m3s
CI / test (push) Successful in 8m44s
Release / notes (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:12:08 +02:00
Ilker ManapandClaude Opus 5.5 5967e93187 ComfyUI's output goes through the gate, which keeps reading it
ComfyUI wrote straight to the agent's pipe. An agent that restarts and
adopts the replica leaves that pipe with no reader, and ComfyUI's next
progress bar failed with a broken pipe, failing the workflow. It now
writes to a pipe of the gate's, which reads for as long as ComfyUI writes
and passes on what it can.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 01:54:39 +02:00
Ilker ManapandClaude Opus 5.5 e344db5fa5 An installed ComfyUI says its version and its torch's
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 01:41:11 +02:00
Ilker ManapandClaude Opus 5.5 fe41b5eff3 ComfyUI is installed by the agent, from its source checked against a digest
engines.comfyui in the cluster's settings has every NVIDIA node build
ComfyUI: the release's source archive from GitHub, checked against the
digest the recipe holds — GitHub publishes none for a tag's archive, so a
version without one is refused — unpacked beside a Python environment
whose dependencies are its requirements file resolved as PyPI stood the
day after the release. torch is PyPI's CUDA 13 build, or PyTorch's CUDA
12.9 one on an older driver, and the install is checked on the card.

A requirements file that names anything but PyPI packages is refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 01:31:22 +02:00
Ilker ManapandClaude Opus 5.5 c31bac8d4b An awake on-demand model survives a restart of the control plane
A control plane that has just started or become leader has heard no
requests, and read the on-demand replicas its nodes report as wanted by
nobody: it drained every one of them at every failover. An adopted replica
of an on-demand model now gets a whole idle timeout from its adoption.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 01:10:44 +02:00
Ilker ManapandClaude Opus 5.5 b66dd9e6a7 An on-demand model that is up reads as ready, not as not placed
ctl models said "not placed" for a model with min 0 woken by a request and
serving; it now says "ready (on demand)". And the docs: pictures on the
cluster's own cards, with engine comfyui behind its gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 01:01:55 +02:00
Ilker ManapandClaude Opus 5.5 354461d3bf The gate makes the directories ComfyUI dies without
ComfyUI lists its base directory's custom_nodes before anything else and
exits when there is none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 00:55:23 +02:00
Ilker ManapandClaude Opus 5.5 fde1501e8a ComfyUI as an engine of the cluster's own
A model whose profiles run engine comfyui is placed on a node's card like
any other, and makes pictures there. ComfyUI asks for no key, so the agent
starts it behind llmgw comfyui-gate: ComfyUI on the loopback, the gate on
the replica's port demanding the replica's key and letting through only
what the router asks — queueing, waiting, fetching, cancelling. The gate
answers the agent's /health and /v1/models, shows ComfyUI the fetched
weights by their own names, and gives each replica its own base directory.
ComfyUI runs as the gate's child, so its card memory is the replica's.

A node offers the engine when agent.yaml names its ComfyUI
(engines.comfyui.dir). The model's comfyui: block names the workflow and,
for a source that is a set of files, the checkpoint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 00:39:10 +02:00
Ilker ManapandClaude Opus 5.5 57270eebcb The pictures page shows a batch in the order it was made
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 23:04:52 +02:00
Ilker ManapandClaude Opus 5.5 698f82cc12 The pictures page is a session page, not asked for an API key
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 22:32:57 +02:00
Ilker ManapandClaude Opus 5.5 62db7c5586 A page that makes pictures, beside the chat
/images offers the models that make pictures, and the chat no longer
offers them. Like the chat, the browser holds a session and never a key;
the page's request is rebuilt from its own fields and sent down the path
every /v1/images/generations request takes. Size, how many, seed, steps
and a negative prompt; each picture shown with its seed, a download link
and a way to make it again. Nothing is kept but the usage. The menu
offers the page while some model makes pictures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 22:24:43 +02:00
Ilker ManapandClaude Opus 5.5 4a4fe8fa16 Pictures are priced by the picture
auth.pricing takes per_image, charged on top of a prompt's tokens; a model
priced only per picture is not free, so a spent budget still stops it.
Each usage row keeps how many pictures were made (migration 0041), and
ctl request and the admin API's request view show them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 21:48:47 +02:00
Ilker ManapandClaude Opus 5.5 3894e8e5fd Backlog F5: Qwen3-Embedding-4B measured, on llama.cpp and vLLM, and against bge-m3
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 21:36:23 +02:00
Ilker ManapandClaude Opus 5.5 751533f853 Docs: pictures from ComfyUI
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 21:26:31 +02:00
Ilker ManapandClaude Opus 5.5 79a503f824 A picture from ComfyUI loses the workflow written into it
ComfyUI saves the whole workflow — prompt, checkpoint, nodes — as PNG text
chunks in every picture. They are taken out before the picture is sent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 21:22:02 +02:00
Ilker ManapandClaude Opus 5.5 2f06c0e513 Pictures from ComfyUI at /v1/images/generations
An external model with api: comfyui is a ComfyUI server. A request for
pictures goes through the same key, allowlist, limit, admission and guard
checks as any other; at the replica it becomes a workflow — a built-in
(flux-schnell, flux-dev) or a JSON file in API format, with {{prompt}},
{{width}}, {{seed}} and the like filled in — queued, waited on, and its
pictures sent back as b64_json. A client that leaves takes its workflow
off the queue or interrupts it. ComfyUI is never scraped for metrics.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 21:15:50 +02:00
Ilker ManapandClaude Opus 5.5 6b7750a0dd An embedding model's query_prefix, put before questions and never before documents
Qwen3-Embedding and models like it are trained with an instruction on the
query side only. The prefix goes before the question a knowledge-base
search embeds, at answer time and in the admin API's search; documents
reach /v1/embeddings from llmgw-kb as they are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 18:03:00 +02:00
Ilker ManapandClaude Opus 5.5 a133270725 The chat's kept conversation belongs to the account that kept it
It sat in localStorage under one fixed key, so the next person to sign in
on the same browser opened the chat on the last one's conversation. It is
now kept under the account's id, anyone else's is dropped when the page
loads, and signing out sends Clear-Site-Data: "storage" so nothing the
pages kept outlives the session.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 15:44:47 +02:00
Ilker ManapandClaude Opus 5.5 8932502768 Backlog F5: where a large knowledge-base import spends its time
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 15:19:35 +02:00
Ilker ManapandClaude Opus 5.5 92733317a9 A batch of embeddings is checked text by text against the model's context
llmgw-kb sends many passages in one /v1/embeddings request. Their sizes
were added together and compared with the context, so a batch of passages
that each fit was refused as one over-long prompt. The longest input is
what has to fit; the total still counts for usage and limits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 11:43:21 +02:00
Ilker ManapandClaude Opus 5.5 37ad1657ee v0.14.1: changelog and README
Release / build (push) Successful in 1m59s
Release / notes (push) Successful in 5s
CI / test (push) Successful in 8m37s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 10:11:10 +02:00
Ilker ManapandClaude Opus 5.5 8df78a4d41 The firewall setting reaches the mirror machine and the store's members
The leading control plane tells the mirror over its API (PUT /v1/firewall,
kept as .firewall.json beside the models and applied by the same path unit
control planes use), and keeps the state in the store's cluster_state for
members that run no llmgw, which a root timer reads from the member's own
copy. The mirror also lets in the network of every node and control plane
the leader knows. Tried on the PoC: off, on and a network added, on every
kind of machine.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 08:53:17 +02:00
Ilker ManapandClaude Opus 5.5 5be3da505b Keys' project paths written as the API takes them, Acme/ml/search
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 06:46:12 +02:00
Ilker ManapandClaude Opus 5.5 15a209e652 Terraform: organizations, teams, projects, keys and memberships
llmgw_org, llmgw_team and llmgw_project rename and move in place;
llmgw_key holds the key in a sensitive secret and is made again when
revoked or expired outside Terraform; llmgw_membership puts a person in
the tree. Tried with OpenTofu against the fake admin API, drift and
import included.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 06:42:07 +02:00
Ilker ManapandClaude Opus 5.5 90e0942481 Organizations, teams, projects, keys and memberships in the admin API
What llmgw org, keys and users did against the store on a control plane
can now be done remotely and by automation: the tree read, added to,
renamed, moved and emptied; keys issued (the key in the answer, once),
listed and revoked, with the scopes' lifetime limits; people put in and
taken out of the tree, their keys in projects they no longer reach
revoked with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:42:43 +02:00
Ilker ManapandClaude Opus 5.5 9c675e7293 Knowledge bases at scale: halfvec, a text index per collection, ef_search raised to the candidates wanted
A search asking for more than 10 passages got at most 40 candidates from
HNSW, pgvector's default ef_search. The full-text arm scored every chunk
holding a word of the question before limiting, over the whole
organization's GIN index; it now takes candidates by ts_rank from the
collection's own partial index and scores those. New collections index
halfvec on pgvector 0.7 and later (up to 4000 dimensions).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 22:08:29 +02:00
Ilker ManapandClaude Opus 5.5 42d3b7ea22 The store's PostgreSQL sized to its machine's memory
Each Patroni member gets a share of its own RAM in its own config.yml:
shared_buffers a quarter (15% beside a control plane or agent),
effective_cache_size, maintenance_work_mem and work_mem scaled to it, all
clamped. It is read again on every server init --patroni, so a machine
given more memory gets the use of it. A single-machine PostgreSQL init
installs is sized the same way, as a shared machine, instead of a fixed
96MB.

The step itself went in with 85025df by accident, in an early form that
restarted PostgreSQL before Patroni had taken the reload, leaving the old
shared_buffers with a restart pending. It now waits for Patroni to report
the restart pending, restarts with restart_pending (replicas first, the
leader last), and restarts a member it finds pending from an earlier run.

Applied to the PoC: pg01 978MB, ctl02 (control plane beside it) 587MB,
both from 128MB; streaming, lag 0, gateways unaffected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:48:53 +02:00
Ilker ManapandClaude Opus 5.5 85025dfb03 Trained adapters published, private to their owners like the knowledge bases they come from
An adapter now has an owner (org, team, project, user or key): only callers
there see it, and to anyone else it is a model that does not exist, absent
from /v1/models too. A training job's owner defaults to the one collection
its pairs come from, must be able to read every collection they come from,
and pairs from a knowledge base are never made public. ctl train publish
keeps the GGUF or PEFT files on the mirror, which accepts uploads now, and
sets the owned adapter after the usual architecture check.

The knowledge bases' cache_salt follows the collections searched rather
than the organization, so two teams of one organization no longer share
cached passages.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:30:50 +02:00
Ilker ManapandClaude Opus 5.5 964b35ae65 Backups reach a replicated store: pg_dump is given the cluster's authority with verify-full
pgEnv passed sslmode=verify-full but dropped sslrootcert, so libpq looked
for ~/.postgresql/root.crt and both backup and restore failed against the
Patroni store. sslrootcert, sslcert and sslkey now go along as PGSSL*.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 21:04:46 +02:00
Ilker ManapandClaude Opus 5.5 88cbf11570 Training finds the engines where the installer puts them when agent.yaml names no directory
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:55:50 +02:00
Ilker ManapandClaude Opus 5.5 79b3e9f887 LoRA adapters trained on the nodes: llmgw ctl train
A job names the catalog model the adapter is for, the safetensors weights to
train on, the node and the MiB of a card it needs, and question/answer pairs.
The control plane sends it when the node's agent is there and nothing else
trains on it; the agent trains with its installed vLLM's torch and a pinned
overlay of peft and accelerate (no second environment), as the engines'
user, on the card with that much free — beside an engine, if it leaves that
much — converts the adapter to GGUF with the installed llama.cpp's own
conversion code when the model is served by llama.cpp, and sends what it
made back in pieces, kept in the store to download. Progress, loss and peak
card memory are reported as it goes; a node that goes fails its job.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:25:57 +02:00
Ilker ManapandClaude Opus 5.5 ae95ac8261 Training data from a collection: llmgw ctl kb dataset
A chat model of the cluster reads each chunk and writes questions a person
might ask of it with the answers it holds, streamed as NDJSON a chunk at a
time, for people to correct before an adapter is trained on it. A chunk that
gave no pairs says so on its own line.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 20:04:37 +02:00
Ilker ManapandClaude Opus 5.5 0a6a586dfe Backlog F4: a LoRA trained in the GPU's spare 2 GB on the PoC, and what it learned
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 19:31:53 +02:00
Ilker ManapandClaude Opus 5.5 c52b73feec Reference: the agents section of the cluster's settings
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:07:00 +02:00
Ilker ManapandClaude Opus 5.5 dc243f8840 The nodes' agent settings kept with the cluster's, agent.yaml down to what an agent needs to start
agents.default and agents.nodes.<id> in the cluster's settings hold
everything agent.yaml can say but the agent's id, control planes, address and
certificate, which stay in the file and are refused there. The control plane
sends each node its settings on register and, a second apart, when they
change; an agent whose settings changed checks they load beside its file,
keeps them and starts again on them, engines serving throughout. An edited
agent.yaml that loads restarts the agent too. History, audit, ctl config, the
console and the portal's settings page cover them as the rest of the
settings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 18:06:36 +02:00
Ilker ManapandClaude Opus 5.5 659f20dc9a The cluster's settings edited on the portal, by administrators
/admin/settings edits the document llmgw ctl config edit opens: checked as a
control plane would load it, refused when made from a version since replaced
(the edit kept), its history viewed and rolled back as a new version, every
change audited by size and never by contents, since it can hold webhook
secrets. Administrators only; in English and Turkish.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 17:23:13 +02:00
Ilker ManapandClaude Opus 5.5 6bc6a67fb0 README: the releases since v0.11.0 as released, and knowledge bases
CI / test (push) Successful in 7m51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 17:17:40 +02:00
Ilker ManapandClaude Opus 5.5 54570066e3 v0.14.0
Release / build (push) Successful in 6m21s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 9m34s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 16:22:07 +02:00
Ilker ManapandClaude Opus 5.5 99829b3124 Backlog: F3's gaps closed and checked on the PoC, cache_salt against a real vLLM
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 16:16:00 +02:00
Ilker ManapandClaude Opus 5.5 26630274f4 Passages of one organization never share a vLLM prefix cache with another's
A request given passages carries a cache_salt derived from its organization
when the replica chosen is vLLM, so how fast an answer starts cannot say what
another tenant's documents held. Other engines are not sent the field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 16:02:19 +02:00
Ilker ManapandClaude Opus 5.5 5ee8e74de1 The portal's chat answers from the documents a person picks
A Documents picker on /chat offers the collections the person may read in
the chosen project, the default when there is one, or none; the sources an
answer was given are listed under it. The page's choice becomes the
X-Llmgw-Knowledge header; one the page sent itself is dropped. In English
and Turkish.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:59:23 +02:00
Ilker ManapandClaude Opus 5.5 0093cc3d45 Collections a part of the tree answers from when a request names none
On an organization, team, project or key, the narrowest winning: llmgw ctl
kb default, POST /api/v1/kb/defaults, and the console's Knowledge screen. A
request's own X-Llmgw-Knowledge overrides it and none turns it off. A default
never refuses a request: a collection the caller cannot read is left out, and
a failed search answers without passages. Removing a collection takes it out
of every default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:52:43 +02:00
Ilker ManapandClaude Opus 5.5 54ba04c52a llmgw-kb collections names each collection's owner as people know it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:42:40 +02:00
Ilker ManapandClaude Opus 5.5 496625824b Chat requests answered from knowledge bases
A request naming collections in X-Llmgw-Knowledge has its last question
embedded on the caller's account, the best passages found and added to the
system message, numbered, with an instruction to cite them and to say when
they do not hold the answer; x-llmgw-sources says which were given. Done
before the guardrails judge the request, never served from the answer cache,
and a collection the key may not read is answered as one that does not
exist. Metrics llmgw_kb_retrievals_total and llmgw_kb_retrieval_seconds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:27:50 +02:00
Ilker ManapandClaude Opus 5.5 65116064d2 Backlog: F3 uses the search as it now scores
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:12:26 +02:00
Ilker ManapandClaude Opus 5.5 3a3535b310 Backlog: knowledge bases tried on the PoC, and what it showed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:12:17 +02:00
Ilker ManapandClaude Opus 5.5 2c7b682587 Knowledge search counts a text match by how many of the question's words it holds
The best text match took the whole text weight, however little of the
question it matched: one everyday word ("most") put the remote-work page
above the travel one for "the most I can spend on a hotel". Now a chunk
gains the weight in proportion to the share of the question's words it
holds, counted once whatever their case.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:09:24 +02:00
Ilker ManapandClaude Opus 5.5 bc5ee9f3aa Knowledge search ranks by how near a chunk's meaning is, not by its place in two lists
Fused by rank, a full-text match on a question's everyday words outvoted a
far nearer meaning: on the PoC, "how much leave do fathers get" found the
annual-leave page before the paternity one (cosine distance 0.44 against
0.25). Now a chunk scores its similarity to the question plus up to 0.3 for
a full-text match, each search brings four times k candidates, the text
search drops Turkish and English stop words, and an operator's search says
when the embedding model did not answer rather than quietly searching text.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 15:05:18 +02:00
Ilker ManapandClaude Opus 5.5 1ec9b83076 Backlog: PostgreSQL tests of several packages run one package at a time
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:59:17 +02:00
Ilker ManapandClaude Opus 5.5 eec0d92d18 The console's Knowledge screen, and the manual's page on knowledge bases
Collections listed with owner, model, documents and grants; A makes one; on a
row its documents, grant, revoke, a search to try, delete. docs/knowledge.md
says what administrators do and how people use llmgw-kb on Windows, macOS
and Linux: what it reads, what leaves their machine, where its ledger is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:58:12 +02:00
Ilker ManapandClaude Opus 5.5 6466f37d50 Knowledge bases administered: llmgw ctl kb and /api/v1/kb
Collections made for an organization, team, project or person, their
dimension asked of the embedding model; granted and revoked by path, never
outside their organization; their documents listed and removed; and a search
an operator can try. In the OpenAPI document.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:49:44 +02:00
Ilker ManapandClaude Opus 5.5 ffab3c99e3 llmgw-kb built for Windows, macOS and Linux in CI and attached to releases
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:44:53 +02:00
Ilker ManapandClaude Opus 5.5 ba5df90602 llmgw-kb: sync a person's documents into a knowledge base from their own machine
Its own binary for Windows, macOS and Linux, carrying no server code. It reads
the files there, cuts them, has the gateway embed them with the person's key
and sends text and vectors for what changed only: a SQLite ledger on the
machine remembers each document's file, size, time, digest and what happened,
so a rerun reads nothing unchanged, a file skipped stays skipped until
--retry, and --delete removes only what this machine sent under the paths
given. --dry-run says what would leave the machine. Commands: login,
collections, sync, status, history, formats.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:43:47 +02:00
Ilker ManapandClaude Opus 5.5 d9e030e0b6 Knowledge bases on the client listener: list, manifest, put and delete a document
What llmgw-kb, run by a person with their own key, reads and writes. A
collection the key may not read answers as one that does not exist; writing
needs ownership or a write grant on the key's chain; a personal key names its
project in X-Llmgw-Project and must be a member of it. Changes are audited by
document name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:27:14 +02:00
Ilker ManapandClaude Opus 5.5 a0cfafe772 Text from documents of many formats, and chunks of it, for knowledge bases
PDF, Word, Excel, PowerPoint, OpenDocument, RTF, EPUB, HTML, MHTML, mail with
its attachments, notebooks, subtitles, zip archives and text and source files
in any common encoding, in pure Go; legacy Office formats through LibreOffice
and scanned pages through Tesseract when they are installed. Chunks of about
500 tokens under their headings, overlapping by a paragraph.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 13:24:15 +02:00
Ilker ManapandClaude Opus 5.5 d35630defe Knowledge bases in the store: collections, grants and chunks kept apart per tenant
Collections owned by an organization, team, project or person; grants that
add up down the tree and never cross an organization; chunks in PostgreSQL
with pgvector, partitioned by organization, an HNSW index per collection, and
row-level security as a second line behind each query's filter. Search fuses
nearest-neighbour and full-text ranks. Off on SQLite and without pgvector,
saying why. server init and the HA database setup install and enable it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 12:47:57 +02:00
Ilker ManapandClaude Opus 5.5 0a7fb821d8 v0.13.0
CI / test (push) Failing after 4m47s
Release / build (push) Successful in 4m54s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:55:34 +02:00
Ilker ManapandClaude Opus 5.5 7f7e5a470e guard judge says controversial, and names a judge no policy asks as one
judge ask showed Qwen3Guard's controversial verdict as "safe (Violent)"; it
now says controversial, which a policy's judge.controversial counts. The
stray-judge note called a judge no policy asks any more "served as a model
clients use"; a model with role: guard is now said to be a judge no policy
asks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:42:55 +02:00
Ilker ManapandClaude Opus 5.5 1fabb05fb2 A replica restarted in place keeps the room it frees for its replacement
The replica was stopped and the pass went on to the other models before
placing its replacement, so a model waiting for room took the freed cards.
On the PoC a GPU judge waiting for the only card took it from
qwen3.8-35b-a3b-distill, restarted there after its definition changed,
until the judge was stopped by hand. With the waiting model first in the
catalog it happened one pass later. The replacement is now placed in the
same step that stops the old replica.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:42:55 +02:00
Ilker ManapandClaude Opus 5.5 457a1eefac Backlog F: company documents through retrieval, isolated per tenant, then adapters
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:35:38 +02:00
Ilker ManapandClaude Opus 5.5 0bf7e46fbf llmgw ctl tool-check asks with the engine's key
Every replica's engine demands its own key, and the check sent none, so it
failed with a 401 on every engine in the cluster. The key is asked of the
admin API for the replica at --endpoint, as bench does, or given with --key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 11:24:37 +02:00
Ilker ManapandClaude Opus 5.5 b0273ffb5f Qwen3Guard is the judge, and judges on a GPU and on CPUs are asked in turn
Llama Guard 3 1B could not tell ordinary coding requests from harmful ones:
on the PoC it scored "continue" 0.51, "continue the code above" 0.89 and
"how do I kill all child processes in bash" 0.92 unsafe, against 0.97 to
1.00 for harmful requests, at Q4_K_M and Q8_0 alike, with its own template.
A site had "continue" refused. Qwen3Guard-Gen 0.6B gave the ordinary ones
0.15 at the most and the harmful ones 1.00, in 0.8 GB on CPUs.

- format qwen3guard: its prompt written out as its template writes it, for
  requests and answers, and its Safe / Unsafe / Controversial verdict read;
  controversial counts as the policy's judge.controversial says, safe by
  default.
- judge.models: judges asked in turn, the first that answers deciding; a
  failure is recorded against the judge that failed. guard judge use sets
  the order, replacing categories none of the judges names.
- known judges (Qwen3Guard 0.6B/4B/8B, Llama Guard 1B/8B): guard judge
  enable serves any of them, --gpu the 4B on the roomiest GPU class as a
  standard model; the leader fetches and serves one a policy names that the
  catalog lacks.
- server init installs qwen3guard-0.6b with the baseline's categories.
- a judge reads a long text by its start and end, which fit its context,
  rather than overflowing it and refusing the request under fail: closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-08 10:22:47 +02:00
Ilker ManapandClaude Opus 5.5 63f7c8cfd7 llmgw start --wait waits for the models, not for the setting
CI / test (push) Successful in 3m57s
Every model of a stopped system asks for no replica and so has what it asks
for. Until the control plane took the setting up, seconds later, start
--wait saw 18 of 18 satisfied and returned at once, with nothing started.
It now counts only once the control plane says it is in the state asked
for, both ways.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 21:43:45 +02:00
Ilker ManapandClaude Opus 5.5 2d14918b99 An adopted engine that turns ready after registration is reported
The registration carries each shard's state as it is then. An adopted
engine is health-checked after that, and nothing reported the ready that
followed, so when the agent registered first the control plane kept the
replica starting while it served. After llmgw restart on the PoC two
replicas stayed so, one the only replica of qwen2.5-coder-14b. The
session's watcher now reports every shard the registration called not
ready once it is.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 19:43:13 +02:00
Ilker ManapandClaude Opus 5.5 3f8cc06f72 llmgw restart goes on past a control plane it cannot reach
One standby that ssh could not log in to stopped the whole restart before
this control plane's own. It is now said and left, the rest restarts - the
leader last - and the command fails at the end naming what was not
restarted and how to do it there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 18:55:52 +02:00
Ilker ManapandClaude Opus 5.5 7245dbe1dd llmgw stop, start and restart act on the whole system
Issue #3 asked for commands to stop and start the system, and 05aa21c gave
only one machine's services. Now:

- llmgw stop sets serving.stopped in the cluster's settings, live: the
  reconciler wants every model at no replicas, freeing every GPU, and the
  gateway answers every POST under /v1/ with 503 system_stopped. The control
  planes, agents and mirror keep running, and no model's definition changes.
- llmgw start clears it; every model is placed again as it asks.
- llmgw restart has every agent restart over its stream (RestartAgent), then
  the other control planes over ssh, standbys before a leader, then this
  one; engines keep serving.
- llmgw status is the cluster's summary, saying when it is stopped.

One machine's services moved to llmgw service status|start|stop|restart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 18:01:45 +02:00
Ilker ManapandClaude Opus 5.5 05aa21c4d4 llmgw status, start, stop and restart for this machine's services
CI / test (push) Successful in 3m44s
There was no command to stop or start llmgw on a machine (issue #3). These
act on the llmgw units the machine has - mirror, server, agent - or those
named, in dependency order, saying what each did. stop leaves a node's
engines serving unless --engines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 14:52:52 +02:00
Ilker ManapandClaude Opus 5.5 2f595f5999 An adopted engine's state is written without its command; engine-check finds the installed build
CI / test (push) Successful in 3m46s
SetCompute on an engine adopted from an earlier agent called writeState,
which read the command line from sh.cmd - nil for a process this agent did
not start - and the agent crashed at every start on the PoC's nodes until
fixed. An adopted shard now keeps the command line its state file had.

engine-check looked for llama-server one directory too shallow; the agent
installs it under <version-variant>/llama-<build>/. It now looks at both,
preferring a CUDA build on a machine with NVIDIA GPUs. Run on cuda01: a
token computed on CUDA0, 25 of 25 layers offloaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 14:26:28 +02:00
Ilker ManapandClaude Opus 5.5 b5d650af16 What a replica computes on comes from nvidia-smi; llama.cpp says its layers only at -lv 4
Checked against the installed llama.cpp (b10991): at its default verbosity
it prints neither the devices it uses nor the layers it offloads, so the
start-up output said nothing and every replica's ON column stayed empty.
The agent now takes compute from nvidia-smi - the GPUs the shard's processes
hold memory on, and how much - or CPU, for every engine, falling back to it
when llama.cpp's output says nothing; adopted engines get it too, and each
session reports it once per shard as a compute-only status. engine-check
runs its GPU token at -lv 4, where llama.cpp does say where its layers went.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 14:04:08 +02:00
Ilker ManapandClaude Opus 5.5 25b3e129fe What a shard computes on survives the agent's and the control plane's restarts
It was sent once, with READY, and kept only in the leader's memory: a
failover or an agent restart lost it. The agent now keeps it with the
shard's state on disk (ShardSpec.Compute, set by Supervisor.SetCompute) and
reports it with each running shard when it registers (RunningShard.compute),
which adoption carries onto the replica.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:51:40 +02:00
Ilker ManapandClaude Opus 5.5 0add189e02 A standby that takes over waits for the fleet before placing
The settling window ran from process start, so a standby that took over
after a minute placed at once, before the agents reconnected and reported
what they run: it started second copies of replicas it did not know of and
drained the healthy ones when they were adopted. Seen on the PoC: a leader
restart replaced llama-guard-3-1b and a qwen2.5-1.5b replica. Reconcile now
reopens the window on the first pass after standing by.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:40:28 +02:00
Ilker ManapandClaude Opus 5.5 66f702d5c1 A forgotten password is set through a one-time link
/forgot mails a link to set a new password when the cluster's mail settings
say how - SMTP relay, sender, and the public base_url the link points at,
never the request's Host - and says the same whether or not the address has
an account. /reset sets it once, closing every session. Links are kept by
their hash in password_resets, good for an hour, a newer one replacing an
older. Without mail, /forgot says to ask an operator, and llmgw users
reset-link makes the link to hand over. Single sign-on accounts get none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:20:11 +02:00
Ilker ManapandClaude Opus 5.5 f2b2e24e1f A node's own readiness and its multi-node readiness are kept apart
doctor sums findings up as two readinesses - the node's, which decides
whether it joins, and multi-node, its fabrics for replicas spanning nodes -
prints fabric findings in a section of their own, and gives both in --json.
node add says the multi-node one. NodeView.multi_node says whether a node's
declared fabrics are up, naming adapters that are down, and ctl nodes shows
it beside the fabric.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:11:16 +02:00
Ilker ManapandClaude Opus 5.5 5ae870e1b2 ctl restart --max-surge and --max-unavailable
A model's replicas restarted one at a time, always. RestartPace says how
many restart at once: MaxSurge beside themselves (replacement first),
MaxUnavailable stopped first (no room needed, out while loading); the
reconciler begins that many per model per pass. The admin API takes
max_surge and max_unavailable on POST /api/v1/restart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:09:10 +02:00
Ilker ManapandClaude Opus 5.5 619dd751ab Nodes show their engines' versions and GPUs' capability; a profile can ask for an engine version
NodeView carries engine_versions and compute_capabilities; ctl nodes shows
llamacpp b10991 and 6000-24g (7.5). A profile's engine_version restricts it
to nodes whose engine version starts with it, and explain-placement says
which version a node has, or which engines it has when it lacks the one
asked for.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:06:57 +02:00
Ilker ManapandClaude Opus 5.5 e4e15200d6 engine-check computes a token on the GPU, and finds the installed llama.cpp
Seeing a device is not computing on it. For llama.cpp on a machine with
NVIDIA GPUs, engine-check now loads a small GGUF (--model, or the smallest in
the weights cache) with every layer on the GPU, asks for one token, and
reads from llama.cpp's own output where the layers went: a build that
computes on the CPU fails, naming the fix. With no --bin it checks the build
the agent installed under /opt/llmgw/engines.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:04:38 +02:00
Ilker ManapandClaude Opus 5.5 0bd0d881c5 A replica says what its engine computes on, and llama.cpp's word on the CPU refuses a GPU placement
llama.cpp's start-up output is read as a shard becomes ready: the devices it
used, its backend, and the layers it offloaded. That goes up with READY in a
new ShardStatus.compute, is kept on the shard, and is shown by the admin
API, ctl replicas (ON) and the console (COMPUTE). A shard given GPUs whose
engine says it found no usable GPU or offloaded no layer is stopped and
failed, even where nvidia-smi cannot be asked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:02:17 +02:00
Ilker ManapandClaude Opus 5.5 f285aa0548 v0.12.2
Release / build (push) Successful in 4m41s
CI / test (push) Successful in 4m43s
Release / notes (push) Successful in 3s
Guardrails off and on; a judge's refusal names the hazard; ctl guard judge ask.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 13:35:16 +02:00
Ilker ManapandClaude Opus 5.5 4721b8e353 Llama Guard 3's verdicts carry their hazards' names without a categories list
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 12:44:53 +02:00
Ilker ManapandClaude Opus 5.5 249a91aeec Guardrails turn off and on; a judge's refusal says which hazard; ctl guard judge ask
guard.enabled (unset is on) makes every policy check nothing while false,
live: llmgw ctl guard off/on set it in the cluster's settings, and ctl
status, ctl guard list and the guardrails page say when they are off.

A refusal by a judge names the hazard it saw - judge: S6,Specialized
Advice - and the verdict is kept with the request in a new usage column,
guard_rule, shown by ctl request, the request log and the guardrails page.

llmgw ctl guard judge ask has a judge read a text, past the cache, and shows
its raw answer and how it is read; the endpoint is kept out of the audit so
no text is recorded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 12:41:08 +02:00
Ilker ManapandClaude Opus 5.5 3f5a809373 v0.12.1
CI / test (push) Failing after 4m20s
Release / build (push) Successful in 4m36s
Release / notes (push) Successful in 3s
The guardrails' judge served and watched without server init.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:52:24 +02:00
Ilker ManapandClaude Opus 5.5 a8bb417618 The guardrails' judge has its own command, and its trouble is shown
llmgw ctl guard judge status lists each judge model the policies ask:
in the catalog, as a judge, replicas ready, its last failure to answer, and
what is wrong in words. enable serves the missing one through model serve -
Llama Guard 3 1B on CPUs, role guard, under the name the policies give it -
with --replace for a model of that name that is not a judge; the console has
it under Do. GET /api/v1/guard/judges, and the summary's judges, put the
problem on ctl status and the console's Overview.

A Llama Guard file served from the mirror page is added as a judge, under
the name the policies ask, rather than as a model clients use the policies
never find. ctl says plainly when the API answers with a page.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 10:47:37 +02:00
Ilker ManapandClaude Opus 5.5 2a942409ea v0.12.0
CI / test (push) Successful in 4m40s
Release / build (push) Successful in 4m44s
Release / notes (push) Successful in 3s
The cluster's settings in the store, in force when changed; the firewall
switch; llmgw wipe; the mirror's address one the nodes reach.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:39:54 +02:00
Ilker ManapandClaude Opus 5.5 ad04a78343 The mirror's address is one the nodes reach, and node add checks it
server init and mirror init took the first non-loopback address for the
mirror's --base, Docker's 172.17.0.1 on a machine with Docker; nodes asked
their own bridge for every model and were refused. primaryAddr takes the
default route's source, else the first address on an interface that is not
a container's, bridge's or overlay's. node add has the node reach the
mirror's files during the checks and fails, naming the address and the fix,
when it cannot.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 09:36:09 +02:00
Ilker ManapandClaude Opus 5.5 32c3942040 The firewall turns off and on with the cluster's settings; llmgw wipe
firewall.enabled (default true) is a cluster setting, applied live: the
leader sends a Firewall message to every agent when it registers and when
the setting or firewall_networks changes, and the agent (root) takes llmgw's
nft table out or installs it. A control plane writes the state to
/var/lib/llmgw/firewall.json, which llmgw-firewall-sync.path applies as root
with `llmgw firewall sync`; server init and server add install the units,
`llmgw firewall sync --install` on one set up before. node add and server
add put no firewall on a machine while the setting is off.

`llmgw wipe` removes llmgw from a machine after listing what goes and asking
for its name: units, engines' processes, the nft table, /etc/llmgw,
/var/lib/llmgw, the agent's state, the sudo grant, the CA from the trust
store, keepalived's configuration when llmgw wrote it, and the users.
--models, --store (Patroni member, etcd, or the local database) and --all
take more. The console has it under Do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-06 08:00:06 +02:00
Ilker ManapandClaude Opus 5.5 0730d23b77 ctl guard says where policies live now; E1 done on the PoC
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 18:34:00 +02:00
Ilker ManapandClaude Opus 5.5 6289b01ed6 The cluster's settings live in the store, and every change takes effect
Everything in server.yaml that is not one machine's own is now a versioned
document in the store (cluster_settings), the same on every control plane.
The first start against a store imports the file's cluster keys, with the
baseline guard policy when there is none; after that those keys in a file
are ignored, with a warning.

Each control plane polls the document's version and its own server.yaml's
hash. A change is validated and applied: guard live (Gate.Replace), anything
else by a graceful restart that systemd brings back, standbys first and the
leader 30s later. A version that does not load on a machine is not applied;
at start the newest one that loads is used, so a bad edit cannot keep every
control plane down.

llmgw ctl config show/get/set/unset/edit/history/rollback, the admin API
under /api/v1/settings, and the console's Settings screen, which layers the
store's document over the machine's file, write it. Commands beside the
control plane that read cluster keys (init, node add, server add, backup,
upgrade) read the layered configuration; init --bundle writes offline to
the store once it holds the settings.

A standby that becomes the leader re-reads models, engine choices and
facility holds from the store.

Retires appendGuardIfMissing: the import does it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 15:25:23 +02:00
Ilker ManapandClaude Opus 5.5 cbbfce235e node add lets the node through the control plane's firewall
The control plane's firewall answered only the networks it was installed
with, so a node on another subnet was set up, could not reach 7443 and
showed as lost. node add now admits the node's network (or its address,
when routed) into the local ruleset, keeping everything already there.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 13:27:57 +02:00
Ilker ManapandClaude Opus 5.5 4ff62c5dd2 ctl upgrade asks the server what it runs, and waits for a node as long as it does
A binary installed by hand made ctl upgrade say this machine already ran
the release and leave the old server running. And a node had 15 seconds
to answer an upgrade the control plane gives five minutes, so a node that
was upgrading stopped the run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 12:43:25 +02:00
Ilker ManapandClaude Opus 5.5 dc65ea6899 server init adds the guardrails to a server.yaml from before them
An upgrade replaces the binary and leaves server.yaml alone, so a control
plane installed before guardrails stayed unguarded. Run again, init now
appends the baseline guard section (and so fetches the judge) when the file
has no guard key at all, keeping the old file as server.yaml.bak-<time>.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 12:39:28 +02:00
837 changed files with 106674 additions and 2657 deletions

No files matched your search

+13 -1
View File
@@ -34,11 +34,23 @@ jobs:
run: go vet ./...
- name: Test with the race detector
run: go test -race ./...
# Under the job's limit, so a package that hangs panics with the
# test it was in and where every goroutine waited, rather than the
# job ending at its deadline with nothing said: from 2026-10-15 every
# run did, printing nothing after internal/release.
run: go test -race -timeout 12m ./...
- name: Build
run: go build ./...
# llmgw-kb runs on people's own machines, Windows included, which no
# test here runs on: building it for each catches a Unix-only call.
- name: Build llmgw-kb for every system
run: |
for target in windows/amd64 windows/arm64 darwin/amd64 darwin/arm64 linux/amd64 linux/arm64; do
CGO_ENABLED=0 GOOS="${target%/*}" GOARCH="${target#*/}" go build -o /dev/null ./cmd/llmgw-kb
done
# The Terraform provider is a module of its own, which ./... above does
# not reach. Its tests check its client against docs/admin-api.openapi.json,
# so a change to the admin API that breaks the provider fails here. The
+20
View File
@@ -0,0 +1,20 @@
# Fails when an engine llmgw follows release by release (FreeToken) has
# published a version newer than the one DefaultEngines installs: the failed
# run is the notice to check the new version and move to it. See
# scripts/engine_releases.py.
name: Engine releases
on:
schedule:
# Mondays 06:00 UTC.
- cron: "0 6 * * 1"
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Compare with PyPI
run: python3 scripts/engine_releases.py
+16
View File
@@ -55,6 +55,21 @@ jobs:
(cd dist && sha256sum "${name}" >> checksums.txt)
done
# llmgw-kb, which people run on their own machines to put their
# documents into a knowledge base: Windows too, which llmgw itself
# does not build for.
for target in windows/amd64 windows/arm64 darwin/amd64 darwin/arm64 linux/amd64 linux/arm64; do
os="${target%/*}"; arch="${target#*/}"
ext=""; [ "$os" = windows ] && ext=".exe"
name="llmgw-kb-${version}-${os}-${arch}${ext}"
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-kb
(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
@@ -91,6 +106,7 @@ jobs:
# test the cross-compiled ones, which is why the native build is
# checked rather than assumed.
./dist/llmgw-${version}-linux-amd64 --version
./dist/llmgw-kb-${version}-linux-amd64 --version
cat dist/checksums.txt
# Signed, so a machine that trusts the key built into it installs
+2
View File
@@ -9,3 +9,5 @@ coverage.out
/terraform-provider-llmgw/terraform-provider-llmgw
/terraform-provider-llmgw/examples/.terraform*
/terraform-provider-llmgw/examples/terraform.tfstate*
# What a development server run from the checkout writes beside it.
/secrets/
+1407 -14
View File
File diff suppressed because it is too large. Load diff
+1748 -1
View File
File diff suppressed because it is too large. Load diff
+105
View File
@@ -0,0 +1,105 @@
# Functional Source License, Version 1.1, ALv2 Future License
## Abbreviation
FSL-1.1-ALv2
## Notice
Copyright 2026 Ilker Manap
## Terms and Conditions
### Licensor ("We")
The party offering the Software under these Terms and Conditions.
### The Software
The "Software" is each version of the software that we make available under
these Terms and Conditions, as indicated by our inclusion of these Terms and
Conditions with the Software.
### License Grant
Subject to your compliance with this License Grant and the Patents,
Redistribution and Trademark clauses below, we hereby grant you the right to
use, copy, modify, create derivative works, publicly perform, publicly display
and redistribute the Software for any Permitted Purpose identified below.
### Permitted Purpose
A Permitted Purpose is any purpose other than a Competing Use. A Competing Use
means making the Software available to others in a commercial product or
service that:
1. substitutes for the Software;
2. substitutes for any other product or service we offer using the Software
that exists as of the date we make the Software available; or
3. offers the same or substantially similar functionality as the Software.
Permitted Purposes specifically include using the Software:
1. for your internal use and access;
2. for non-commercial education;
3. for non-commercial research; and
4. in connection with professional services that you provide to a licensee
using the Software in accordance with these Terms and Conditions.
### Patents
To the extent your use for a Permitted Purpose would necessarily infringe our
patents, the license grant above includes a license under our patents. If you
make a claim against any party that the Software infringes or contributes to
the infringement of any patent, then your patent license to the Software ends
immediately.
### Redistribution
The Terms and Conditions apply to all copies, modifications and derivatives of
the Software.
If you redistribute any copies, modifications or derivatives of the Software,
you must include a copy of or a link to these Terms and Conditions and not
remove any copyright notices provided in or with the Software.
### Disclaimer
THE SOFTWARE IS PROVIDED "AS IS" AND WITHOUT WARRANTIES OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING WITHOUT LIMITATION WARRANTIES OF FITNESS FOR A PARTICULAR
PURPOSE, MERCHANTABILITY, TITLE OR NON-INFRINGEMENT.
IN NO EVENT WILL WE HAVE ANY LIABILITY TO YOU ARISING OUT OF OR RELATED TO THE
SOFTWARE, INCLUDING INDIRECT, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES,
EVEN IF WE HAVE BEEN INFORMED OF THEIR POSSIBILITY IN ADVANCE.
### Trademarks
Except for displaying the License Details and identifying us as the origin of
the Software, you have no right under these Terms and Conditions to use our
trademarks, trade names, service marks or product names.
## Grant of Future License
We hereby irrevocably grant you an additional license to use the Software under
the Apache License, Version 2.0 that is effective on the second anniversary of
the date we make the Software available. On or after that date, you may use the
Software under the Apache License, Version 2.0, in which case the following
will apply:
Licensed under the Apache License, Version 2.0 (the "License"); you may not use
this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed
under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
CONDITIONS OF ANY KIND, either express or implied. See the License for the
specific language governing permissions and limitations under the License.
+1
View File
@@ -6,6 +6,7 @@ LDFLAGS := -X '$(MODULE)/internal/version.Version=$(VERSION)'
build:
go build -ldflags "$(LDFLAGS)" -o bin/llmgw ./cmd/llmgw
go build -ldflags "$(LDFLAGS)" -o bin/llmgw-kb ./cmd/llmgw-kb
test:
go test ./...
+153 -37
View File
@@ -3,8 +3,9 @@
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).
It serves LLMs running on your own GPU and CPU servers (vLLM, SGLang,
llama.cpp, TensorRT-LLM) behind one OpenAI- and Anthropic-compatible
endpoint, and answers from your own documents. Design document: [llmgw-plan.md](llmgw-plan.md).
## Getting started
@@ -29,7 +30,7 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
| v0.1.0 | 0 | Skeleton and sim-engine | released |
| v0.2.0 | 1 | Static router (filter/score/pick, streaming proxy) | released |
| v0.3.0 | 1.5 | Admission control (priority bands) | released |
| v0.4.0 | 2 | Agent: GPU/IB discovery, engine supervision | released |
| v0.4.0 | 2 | Agent: GPU/IB discovery, engine supervision | released as part of v0.5.0 |
| v0.5.0 | 3 | Control plane: catalog, placement, reconciler, ctl | released |
| v0.6.0 | 4 | Multi-node placement and fabric awareness | released |
| v0.7.0 | — | Conversations, agent memory, disk management | released |
@@ -37,45 +38,119 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
| v0.9.0 | — | Managing people and the tenancy tree | released |
| v0.10.0 | — | InfiniBand fabrics and NVLink, doctor, node add, engines installed by the nodes | released |
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | released |
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves; v0.14.6: setup that explains every part and lets it be chosen, the gateway on all three HA machines; v0.14.7: the judge sized for its machine; v0.14.8: sign-in with Active Directory, several keys for one outside provider; v0.14.9: sign-in set up from the console, a node's console that shows the machine) |
| v0.15.0 | — | The company's MCP tools and VS Code through the gateway; guardrails that ask outside services and put redacted values back; answers to similar questions | released (v0.15.1: Strata, a 125B model of experts on one card; a second mirror kept in step; pickled weights refused; a served model's context changed; v0.15.2: a served model moved to more cards; v0.15.3: a model stopped and kept, or removed with its files) |
| v0.16.0 | — | Weights pinned to a commit and checked against what Hugging Face published; documents embedded by the gateway; Strata offered where models are chosen; cluster upgrades that say how far they are | released (v0.16.1: the mirror's Strata tab, Strata's ready-made packs; v0.16.2: standbys that answer clients, FreeToken, a model fetched once per node; v0.16.3: replicas per profile, a served model's settings on the mirror page, the CPU's speed before serving) |
| v1.0.0 | 5 | What phase 5 has left: precise KV routing proven on vLLM, the block cost picker, defragmentation across NVLink domains, prefill/decode disaggregation | planned |
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.
Since v0.11.0, not yet released (see [CHANGELOG.md](CHANGELOG.md),
"Unreleased"):
Since v0.11.0 (each release's detail is in [CHANGELOG.md](CHANGELOG.md)):
- **Security hardening** — signed releases only, a token on the admin API,
engines not run as root, TLS for the agent port, etcd and Patroni, a
firewall for llmgw's internal ports, encrypted backups, bounded
connections, sign-in throttling, a Content-Security-Policy.
- **Guardrails** — policies that check requests and answers (PII, secrets,
prompt injection, deny patterns, a judge model such as Llama Guard), in
observe or enforce mode, attached to any part of the tenancy tree:
- **Setup that explains, and high availability that is whole** (v0.14.6).
`sudo llmgw` lists the parts of an install, each with what it is for and
what is missing without it, and every one can be turned off or set: the
starter model and its context, the mirror's directory, the judge's
categories and what it does when it does not answer, limits, the
portal's language, pictures and 3D. With three machines each runs the
control plane, with a floating address on the leader, and any one can
be lost: on three VMs, cutting the store primary's power lost no
request. One ComfyUI serves an environment's models, pictures, 3D and
workflows are MCP tools, and `/v1/messages` gives a model's reasoning as
thinking blocks.
- **Picture and 3D models that add themselves** (v0.14.5). Where a node's
card fits flux-schnell or triposr and the catalog makes nothing of that
kind, the leader adds it on demand, so a new cluster's portal has its
Images and 3D pages; the cluster page and the console show each preset
and add it by hand, and `presets.auto` turns it off. Stable Fast 3D's
colours can be brought to the picture's (`albedo_match`), and every mesh
carries its whole licence.
- **Any ComfyUI workflow, and models by name** (v0.14.4). `POST
/v1/workflows/runs` runs a ComfyUI model's workflow as a job, its inputs
(text, numbers, choices, images, masks, audio, video, files) declared in
the catalog and listed in `/v1/models`. `llmgw ctl model add triposr` or
`flux-schnell` adds a tested model with no YAML; a profile may name the
card it needs instead of a class, and ComfyUI now installs on V100s. A
licence carries what a machine can match, and a person can leave
licences out on the profile page so no key of theirs reaches such a
model. A 3D job waiting for the one card says what holds it and for
about how long.
- **3D models as jobs** (v0.14.3). `POST /v1/3d/generations` answers with a
job, asked after until its mesh is ready to download. ComfyUI makes it on
the cluster's own cards, TripoSR being the tested model. The job says how
far along it is, the model's licence is written into the GLB, and
`per_mesh` prices it. People make them at `/3d` on the portal; `llmgw ctl
3d` shows every owner's jobs. A model profile may also load weights of
its own, and an admission band may wait longer than the rest, for bulk
work.
- **Pictures from ComfyUI** (v0.14.2). `POST /v1/images/generations` is made
by ComfyUI, behind a gate that demands the replica's key, as an engine the
agents install from its source checked against a digest. `per_image`
prices it, and people make pictures at `/images` on the portal.
- **Adapters, and the tenancy tree over the API** (v0.14.1). `llmgw ctl
kb dataset` and `llmgw ctl train` make a LoRA adapter from a collection
on a node's spare GPU memory, kept on the mirror and seen only by its
owner. Organizations, teams, projects, keys and memberships are in the
admin API and the Terraform provider. The agents' settings are edited
with the cluster's, on the portal too, and the firewall setting reaches
every machine.
- **Knowledge bases** (v0.14.0). People put a company's documents in from
their own machines with `llmgw-kb`, a separate download for Windows,
macOS and Linux that reads PDF, Office, OpenDocument, mail and many more
formats and sends text and vectors, never the files. Collections are kept
apart per tenant, down to row-level security in PostgreSQL. A chat
request, or a part of the tree by default, is answered from them with
its sources: [docs/knowledge.md](docs/knowledge.md).
- **One command for the whole system** (v0.13.0): `llmgw stop`, `start` and
`restart`. Qwen3Guard is the guardrails' judge, on a GPU and on CPUs
together.
- **Settings that apply when changed** (v0.12.0). The cluster's
configuration lives in the store, the same on every control plane and in
force within seconds (`llmgw ctl config`, the console's Settings). The
firewall can be switched on and off for the whole cluster.
- **Security hardening** (v0.11.2): signed releases only, a token on the
admin API, engines not run as root, TLS for the agent port, etcd and
Patroni, a firewall for llmgw's internal ports, encrypted backups,
bounded connections, sign-in throttling, a Content-Security-Policy.
- **Guardrails** (v0.11.2). Policies check requests and answers for PII,
secrets, prompt injection, deny patterns, and with a judge model; they
observe or enforce, and attach to any part of the tenancy tree:
[docs/guardrails.md](docs/guardrails.md).
- **Administration** — an audit log of every change (`ctl audit`), staff
roles (viewer, operator, billing, admin), single sign-on through OpenID
Connect and provisioning through SCIM 2.0, key lifetime limits and
rotation with overlap (`keys ttl`, `keys rotate`), usage export as CSV
(`ctl usage export`).
- **Notifications** — webhooks, signed or Slack-shaped, for a model parked,
a node lost, a budget spent and new weights published (`notify.webhooks`,
`ctl notify test`), and Alertmanager receiver templates.
- **Traffic and models** — request tags, canaries of new weights with
automatic rollback, quality suites gating updates, a response cache,
kept and replayed requests, batches, Anthropic's Messages API, more of
OpenAI's (structured output, images, rerank, audio, moderations),
scaling by the clock, SLO-aware admission, TensorRT-LLM, MIG, models
imported from Ollama, routing by what each vLLM replica's cache holds,
LoRA adapters loaded on demand, other llmgw clusters as providers, and
placement that knows racks, PDUs and power caps.
- **Operations** — `server restore` and off-machine backup copies, secrets
in Vault, forgetting a person and keeping a project's requests in the
cluster, offline installs from a bundle, a Helm chart, tracing from the
control plane to the engines, the admin API in OpenAPI with a Terraform
provider, and the portal in English and Turkish with its docs at /docs.
- **Administration** (v0.11.2):
- an audit log of every change (`ctl audit`);
- staff roles (viewer, operator, billing, admin);
- single sign-on through OpenID Connect and provisioning through SCIM
2.0;
- key lifetime limits, and rotation with overlap (`keys ttl`, `keys
rotate`);
- usage export as CSV (`ctl usage export`).
- **Notifications** (v0.11.2): webhooks, signed or Slack-shaped, for a
model parked, a node lost, a budget spent and new weights published, and
Alertmanager receiver templates.
- **Traffic and models** (v0.11.2):
- request tags, canaries of new weights with automatic rollback, and
quality suites gating updates;
- a response cache, kept and replayed requests, and batches;
- Anthropic's Messages API, and more of OpenAI's (structured output,
images, rerank, audio, moderations);
- scaling by the clock, and SLO-aware admission;
- TensorRT-LLM, MIG, and models imported from Ollama;
- routing by what each vLLM replica's cache holds, and LoRA adapters
loaded on demand;
- other llmgw clusters as providers, and placement that knows racks,
PDUs and power caps.
- **Operations** (v0.11.2):
- `server restore`, off-machine backup copies, and secrets in Vault;
- forgetting a person, and keeping a project's requests in the cluster;
- offline installs from a bundle, and a Helm chart;
- tracing from the control plane to the engines;
- the admin API in OpenAPI, with a Terraform provider;
- the portal in English and Turkish, with its docs at /docs.
Every one of these is in the console as well as on the command line:
[docs/console.md](docs/console.md).
@@ -110,6 +185,8 @@ Every page is also in the binary: `llmgw docs`, or **Manual** in the console.
cluster that exists
- [docs/kubernetes.md](docs/kubernetes.md) — the control plane on
Kubernetes with the Helm chart, and why GPU nodes stay outside it
- [docs/kong.md](docs/kong.md) — llmgw behind Kong Gateway, or Kong behind
llmgw: the configuration, what Kong counts, and which to choose
- [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
@@ -124,6 +201,9 @@ Every page is also in the binary: `llmgw docs`, or **Manual** in the console.
official OpenAI and Anthropic SDKs, and llmgw's extensions
- [docs/guardrails.md](docs/guardrails.md) — checking requests and answers:
policies, detectors, a judge model, and where each applies
- [docs/knowledge.md](docs/knowledge.md) — knowledge bases: collections,
who may read them, `llmgw-kb` on people's machines, and answers from
documents
- [docs/choosing-models.md](docs/choosing-models.md) — which models to run,
how many of each, and where the weights should live
@@ -152,7 +232,7 @@ Every page is also in the binary: `llmgw docs`, or **Manual** in the console.
## Building
```sh
make build # bin/llmgw
make build # bin/llmgw and bin/llmgw-kb
make test # unit tests
make race # with the race detector
```
@@ -169,6 +249,7 @@ 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
llmgw ctl audit --since 24h # who changed what
llmgw ctl usage export --from 2026-09-01 --to 2026-10-01 --by project,model
llmgw ctl kb list # knowledge-base collections, grants, defaults
```
Bare `llmgw` opens the console, which does all of this from one screen.
@@ -180,6 +261,33 @@ Add `--json` to any command for machine-readable output.
says nothing about which of six constraints bit, so it prints every candidate
considered and the reason it was rejected.
## What llmgw adds to a request
Measured with `llmgw ctl bench overhead`. It starts an engine that answers
at once and a gateway in front of it, in one process on loopback. It sends
the same load straight to the engine and through the gateway, and reports
what the gateway added. Run on an Apple M5 (10 CPUs, load about 2) on
2026-10-16, 10 s a step, 72,000 requests with none failed:
| Through the gateway | added at p50 | added at p99 |
|---|---|---|
| bare | 0.06–0.24 ms | 0.10–0.33 ms |
| with authentication and usage accounting | 0.05–0.32 ms | 0.09–0.37 ms |
| with the guardrails' pattern checks as well | 0.10–0.42 ms | 0.15–0.49 ms |
The range is across 100 to 2,000 requests a second. The overhead was
*lowest* at 2,000, most likely because a busier processor keeps its cores
awake. The guardrails' judge model is not in these figures: a judgment
takes seconds on CPUs (see [install.md](docs/install.md)) and is a model's
time, not the gateway's.
The command also prints the gateway's CPU per thousand requests. Gateway,
engine and load share one process there, so that figure is rough (0.04 to
0.65 s per thousand in the run above); the latency is the one to quote.
Figures from a VM on a busy host move with the host: run it where you will
deploy, and keep the load it prints beside the result. CI runs a one-second
version on every change (`TestTheGatewaysOverheadIsMeasured`).
## Running the router
```sh
@@ -207,8 +315,9 @@ within 1.24x of idle while 500 concurrent batch requests run, against 58x with
admission off.
Routing profiles select how endpoints are scored: `chat` favours session
affinity, `batch` favours the shortest queue, `long-context` favours free KV
cache. Pick one per model in the config, or per request with the
affinity and the replica that has been answering soonest (its time to first
token, smoothed), `batch` favours the shortest queue, `long-context` favours
free KV cache. Pick one per model in the config, or per request with the
`x-llmgw-profile` header.
## sim-engine
@@ -263,3 +372,10 @@ make rename NEW=git.example.com/team/llmgw
## Language
Code, comments, tests, commit messages and documentation are in English.
## Licence
Functional Source License 1.1 with an Apache 2.0 future licence
(FSL-1.1-ALv2); see [LICENSE](LICENSE). Each release may be used, read and
changed for anything except offering it as a competing product or service,
and becomes Apache 2.0 two years after it is published.
+2012 -254
View File
File diff suppressed because it is too large. Load diff
+286
View File
@@ -28,6 +28,9 @@ message AgentMessage {
FabricTestReply fabric_test = 6;
UpgradeReply upgrade = 7;
KVEvents kv_events = 8;
TrainStatus train_status = 9;
TrainArtifact train_artifact = 10;
SlurmStatus slurm_status = 11;
}
}
@@ -71,6 +74,12 @@ message ControlMessage {
FabricTestRequest fabric_test = 9;
UpgradeAgent upgrade = 10;
ControlPlanes control_planes = 11;
Firewall firewall = 13;
RestartAgent restart_agent = 14;
AgentSettings agent_settings = 15;
TrainStart train_start = 16;
TrainCancel train_cancel = 17;
Slurm slurm = 18;
}
// trace is the W3C trace context (traceparent, and tracestate when there
// is one) of the control plane's span that sent this command, so what the
@@ -80,6 +89,38 @@ message ControlMessage {
map<string, string> trace = 12;
}
// RestartAgent has the agent start again, as llmgw restart asks of every
// node: its engines keep serving and are taken over again.
message RestartAgent {
string reason = 1;
}
// Firewall is whether llmgw's firewall is kept on the node — its nftables
// table, which lets the cluster's networks alone reach the engines' ports and
// the node exporter — sent when an agent registers and whenever the
// cluster's settings change it (firewall.enabled).
message Firewall {
bool enabled = 1;
// networks are let in besides those the node reaches the control planes
// on: firewall_networks, networks or single addresses.
repeated string networks = 2;
// engine_ports_from and engine_ports_to are the engines' ports
// (control.port_range).
int32 engine_ports_from = 3;
int32 engine_ports_to = 4;
}
// AgentSettings is what the cluster's settings say of this node's agent
// (agents.default, with agents.nodes.<id> over it), sent when it registers
// and whenever they change. The agent keeps it beside agent.yaml, which
// holds only what it needs to start; over that file, it is what the agent
// runs on. A change from what it runs on has the agent start again on it,
// its engines serving throughout.
message AgentSettings {
// yaml is agent.yaml's keys, as YAML; empty for none.
string yaml = 1;
}
// ControlPlanes is every control plane an agent may dial, sent by the leader
// when an agent registers and whenever the list changes. The agent keeps it
// beside its configuration, so a second control plane added after the node
@@ -199,6 +240,13 @@ message Host {
// 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;
// mem_bandwidth_gbs is how fast the memory reads, in GB/s, measured when
// the agent started: what decides a model's speed on the CPU. 0 unknown.
double mem_bandwidth_gbs = 7;
// mem_bandwidth_core_gbs is what one core reads alone, in GB/s: a replica
// given a few threads of a large machine reads at a few times this, not at
// the whole machine's rate. 0 unknown.
double mem_bandwidth_core_gbs = 8;
}
// Storage is one filesystem a node keeps weights on.
@@ -250,6 +298,11 @@ message RunningShard {
// 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;
// compute is where its engine said it put the model when it became ready,
// kept by the agent across its own restarts: a control plane that takes
// over, or restarts, learns it here.
Compute compute = 16;
}
message GPU {
@@ -290,6 +343,13 @@ message GPU {
// memory the instance's share; utilization_percent stays zero, because no
// driver measures it per instance.
MIGDevice mig = 22;
// How the card is linked to its host: the fastest generation card and
// slot allow together, the card's own fastest, and the lanes in use and
// the card's most. Zero when the driver did not say.
int32 pcie_gen = 23;
int32 pcie_gpu_gen = 24;
int32 pcie_width = 25;
int32 pcie_width_max = 26;
}
// MIGDevice is which card a MIG instance is carved from, and how.
@@ -394,6 +454,21 @@ message GPUSensor {
int32 last_xid = 26;
string last_xid_text = 27;
int64 last_xid_unix = 28;
// How much of the chip the work on it uses, which utilization_percent
// does not say: that is the share of time any kernel ran, and a kernel on
// one SM of a hundred counts in full. has_profile says these are present;
// -1 is not reported. Read from NVML's GPM on Hopper and later, or from a
// dcgm-exporter on the node; profile_source says which ("gpm", "dcgm").
bool has_profile = 29;
// sm_active_percent is the share of time the SMs had work, averaged over
// all of them; sm_occupancy_percent how full those SMs were with warps.
double sm_active_percent = 30;
double sm_occupancy_percent = 31;
// tensor_active_percent is the tensor cores; dram_active_percent the
// memory interface, which decode is bound by.
double tensor_active_percent = 32;
double dram_active_percent = 33;
string profile_source = 34;
}
// ShardLoad is one engine process group's use.
@@ -544,6 +619,13 @@ message Heartbeat {
// is not handed out. Empty from an agent that does not measure it, or from
// one that could not this time.
repeated int32 gpu_foreign_memory_mb = 19;
// mem_bandwidth_gbs is how fast the node's memory reads, in GB/s, once
// the agent has measured it — in the background after it starts, so a
// registration usually has none yet. 0 means not measured.
double mem_bandwidth_gbs = 20;
// mem_bandwidth_core_gbs is one core's read rate, as in Host.
double mem_bandwidth_core_gbs = 21;
}
// Provision is the software a node should have: which engines, at which
@@ -589,6 +671,18 @@ message ProvisionStatus {
string detail = 5;
}
// Compute is where an engine runs its model, as it said when it loaded it.
message Compute {
// backend is "CUDA", "Vulkan", "Metal" or "CPU".
string backend = 1;
// devices are the devices it named: "CUDA0: Quadro RTX 6000 (7.5)".
repeated string devices = 2;
// layers_offloaded of layers_total went to the devices; both 0 when the
// engine did not say.
int32 layers_offloaded = 3;
int32 layers_total = 4;
}
// ShardStatus reports a shard's lifecycle transition.
message ShardStatus {
string node_id = 1;
@@ -610,6 +704,10 @@ message ShardStatus {
// going to look.
FetchProgress fetch = 8;
// compute is what the engine says it computes on, read from its own output
// as it became ready: sent with STATE_READY when the engine says.
Compute compute = 9;
enum State {
STATE_UNSPECIFIED = 0;
STATE_PENDING = 1;
@@ -630,6 +728,9 @@ message FetchProgress {
// 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;
// phase is what the bytes are: empty while they arrive, "checking" while the
// node checks a set's files against the mirror's checksums.
string phase = 4;
}
// StartShard launches one shard. It is idempotent by shard_id: the control
@@ -689,6 +790,13 @@ message EngineProfile {
// kv_events has a vLLM engine publish its KV cache events to the agent, for
// the router's precise index.
bool kv_events = 13;
// allow_pickle lets the node fetch weights in a pickle format, which run
// code when loaded: the catalog's allow_pickle, set on purpose.
bool allow_pickle = 14;
// model_sha256 is the sha256 the registry published for the file
// model_path names, from the catalog's origin: the node refuses a copy
// that is not it. Empty when nothing is known to check against.
string model_sha256 = 15;
}
// RemoveWeights deletes cached model files a node no longer needs.
@@ -828,3 +936,181 @@ message LoraAdapter {
string name = 1;
string source = 2;
}
// TrainStart has a node train a LoRA adapter (BACKLOG F4): on a base model's
// weights from its registry, on question/answer pairs, with a Python trainer
// the agent carries, in an environment it makes over an installed engine's.
// One at a time per node.
message TrainStart {
string job_id = 1;
// base_repo and base_revision name the base model's weights on its
// registry (Hugging Face): safetensors, not the GGUF a catalog may serve.
string base_repo = 2;
string base_revision = 3;
// data is the training pairs, NDJSON of {"q", "a"}.
bytes data = 4;
// params is the trainer's settings as JSON: rank, alpha, epochs, lr,
// batch, max_len.
string params = 5;
// gpu_memory_mib is what the job needs of a card; the agent starts it
// only when one has that much free, which lets it run beside an engine.
int32 gpu_memory_mib = 6;
// gguf asks for the adapter in llama.cpp's format too.
bool gguf = 7;
}
// TrainCancel stops a training job.
message TrainCancel {
string job_id = 1;
}
// TrainStatus is how a training job is going, sent as it changes.
message TrainStatus {
string job_id = 1;
// state is preparing, running, done or failed.
string state = 2;
int32 step = 3;
int32 steps = 4;
double loss = 5;
// message is what is being done, or why it failed.
string message = 6;
// peak_mib is the most the job held of the card.
int32 peak_mib = 7;
}
// TrainArtifact is a piece of what a training job made, sent before its
// final status: a gzipped tar of the PEFT adapter (kind peft), or the GGUF
// adapter (kind gguf).
message TrainArtifact {
string job_id = 1;
string kind = 2;
int32 seq = 3;
bytes data = 4;
bool last = 5;
}
// Slurm is what this node is in the Slurm cluster run beside llmgw
// (BACKLOG P): its controller, one of its nodes, both or neither. Sent when
// an agent registers and whenever it changes; the agent installs what is
// missing and keeps the daemons running as it says.
message Slurm {
// controller runs slurmctld here, on files.
bool controller = 1;
// node runs slurmd here, configless, fetching its files from conf_server.
bool node = 2;
// name is this node's NodeName in slurm.conf.
string name = 3;
// conf_server is slurmctld's host:port.
string conf_server = 4;
// munge_key is the key every machine of the cluster shares.
bytes munge_key = 5;
// files are the controller's, by name in /etc/slurm; empty for a node
// that is not the controller.
map<string, string> files = 6;
// lent says Slurm may run jobs here now: resume the node if llmgw drained
// it. Otherwise it is drained, its jobs left to finish.
bool lent = 7;
// shared is the file system every machine of the Slurm cluster sees at
// the same path; unset or with no path, none.
SlurmShared shared = 8;
// manage_users has llmgw keep the Linux users below on this machine,
// and take away those it made that are no longer among them.
bool manage_users = 9;
repeated SlurmUser users = 10;
// make_homes has this machine make the users' home directories: the one
// that exports the shared path, else the controller.
bool make_homes = 11;
// containers has the machine install pyxis and enroot, so jobs may run
// in containers (srun --container-image), and set them up.
bool containers = 12;
// manage_accounts has the controller keep Slurm's accounts as accounts
// says: one for each organization, team and project, nested, the users
// on each project's. Only those llmgw made are changed or taken away.
bool manage_accounts = 13;
repeated SlurmAccount accounts = 14;
}
// SlurmAccount is an account in Slurm's accounting, parents before their
// children.
message SlurmAccount {
string name = 1;
string parent = 2;
repeated string users = 3;
}
// SlurmJob is a job that ended, as sacct has it, for llmgw's accounting.
message SlurmJob {
string id = 1;
string user = 2;
string account = 3;
string partition = 4;
string state = 5;
int64 start_unix = 6;
int64 end_unix = 7;
int64 elapsed_seconds = 8;
int32 cpus = 9;
int32 gpus = 10;
}
// SlurmShared is where the Slurm cluster's shared files come from.
message SlurmShared {
// path is where every machine sees them, as /shared.
string path = 1;
// nfs is an export, host:/path, to mount at path; empty when this
// machine exports path itself (serve) or the site mounts it.
string nfs = 2;
// serve has this machine export path over NFS to clients.
bool serve = 3;
// clients are the addresses the export answers.
repeated string clients = 4;
}
// SlurmUser is a Linux user that runs jobs, the same on every machine.
message SlurmUser {
string login = 1;
// uid is also the user's own group's gid.
uint32 uid = 2;
// name is the account's, for the GECOS field.
string name = 3;
string home = 4;
// ssh_keys are OpenSSH public keys, one a line of authorized_keys.
repeated string ssh_keys = 5;
}
// SlurmStatus is what this node's Slurm daemons are doing, sent when it
// changes and every few minutes while the node has a part in Slurm.
message SlurmStatus {
// version is Slurm's, as "23.11.4"; empty when it is not installed.
string version = 1;
// hardware is `slurmd -C` on a node, which slurm.conf must match.
string hardware = 2;
bool slurmd_running = 3;
bool slurmctld_running = 4;
// state and reason are the controller's for this node, from scontrol.
string state = 5;
string reason = 6;
// jobs is how many job steps run on this node now, counted from their
// slurmstepd so a controller that is down does not hide them; -1 when the
// agent cannot tell.
int32 jobs = 7;
// error is the last thing that went wrong keeping Slurm, empty when
// nothing did.
string error = 8;
// files_sha256 is of the controller's files as written, so the leader
// knows they arrived.
string files_sha256 = 9;
// slurmdbd_running is the accounting daemon on the controller, which keeps
// the jobs' records in a MariaDB beside it.
bool slurmdbd_running = 10;
// shared is the shared path as this machine has it: "mounted",
// "exported", "missing", or empty when there is none.
string shared = 11;
// users is how many of llmgw's users this machine has.
int32 users = 12;
// containers is what this machine has for jobs in containers, as
// "pyxis 0.24.0, enroot 4.2.1"; empty while it has not got both.
string containers = 13;
// ended are, on the controller, the jobs that ended in the last day; the
// leader records each once.
repeated SlurmJob ended = 14;
}
+469
View File
@@ -0,0 +1,469 @@
// Command llmgw-kb puts a person's documents into an llmgw knowledge base
// from their own machine (BACKLOG F2): it reads the files there, cuts them
// into chunks, has the gateway embed them with the person's key, and sends
// the text and vectors — only for what changed since the last run, which a
// ledger on the machine remembers.
//
// Its own binary, for Windows, macOS and Linux: it carries none of the
// server, agent or setup code that llmgw does.
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"golang.org/x/term"
"gitea.mynodes.xyz/ilker/llmgw/internal/kb/extract"
"gitea.mynodes.xyz/ilker/llmgw/internal/kb/kbsync"
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
)
func main() {
if err := root().Execute(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
// config is what `login` keeps: the gateway, the key, and for a personal key
// the project it acts in.
type config struct {
Gateway string `json:"gateway"`
Key string `json:"key"`
Project string `json:"project,omitempty"`
CA string `json:"ca,omitempty"`
}
func configPath() (string, error) {
dir, err := os.UserConfigDir()
if err != nil {
return "", err
}
return filepath.Join(dir, "llmgw-kb", "config.json"), nil
}
// loadConfig reads the saved configuration, with the environment over it:
// LLMGW_URL, LLMGW_API_KEY, LLMGW_PROJECT.
func loadConfig() (config, error) {
var c config
if p, err := configPath(); err == nil {
if b, err := os.ReadFile(p); err == nil {
if err := json.Unmarshal(b, &c); err != nil {
return c, fmt.Errorf("%s: %w", p, err)
}
}
}
for env, dst := range map[string]*string{"LLMGW_URL": &c.Gateway, "LLMGW_API_KEY": &c.Key, "LLMGW_PROJECT": &c.Project} {
if v := strings.TrimSpace(os.Getenv(env)); v != "" {
*dst = v
}
}
return c, nil
}
func client(flags *globalFlags) (*kbsync.Client, error) {
c, err := loadConfig()
if err != nil {
return nil, err
}
if flags.gateway != "" {
c.Gateway = flags.gateway
}
if flags.project != "" {
c.Project = flags.project
}
if flags.ca != "" {
c.CA = flags.ca
}
if c.Gateway == "" || c.Key == "" {
return nil, errors.New("no gateway or key: run `llmgw-kb login`, or set LLMGW_URL and LLMGW_API_KEY")
}
h, err := kbsync.NewHTTP(c.CA)
if err != nil {
return nil, err
}
return &kbsync.Client{Base: c.Gateway, Key: c.Key, Project: c.Project, HTTP: h}, nil
}
type globalFlags struct {
gateway, project, ca, ledger string
}
func (g *globalFlags) openLedger(ctx context.Context) (*kbsync.Ledger, error) {
p := g.ledger
if p == "" {
var err error
if p, err = kbsync.DefaultLedgerPath(); err != nil {
return nil, err
}
}
return kbsync.OpenLedger(ctx, p)
}
func root() *cobra.Command {
g := &globalFlags{}
cmd := &cobra.Command{
Use: "llmgw-kb",
Short: "Put your documents into an llmgw knowledge base, from this machine",
Long: `llmgw-kb reads documents on this machine, cuts them into chunks, has the
gateway embed them with your key, and sends the text and vectors to a
knowledge base. Files are never uploaded. Run it as often as you like: a
ledger on this machine remembers what was sent, and only what changed is
read and sent again.`,
Version: version.Version,
SilenceUsage: true,
SilenceErrors: true,
}
cmd.PersistentFlags().StringVar(&g.gateway, "gateway", "", "the gateway's address (default: from login, or LLMGW_URL)")
cmd.PersistentFlags().StringVar(&g.project, "project", "", "the project a personal key acts in (default: from login, or LLMGW_PROJECT)")
cmd.PersistentFlags().StringVar(&g.ca, "ca", "", "a PEM file of certificates to trust besides the system's")
cmd.PersistentFlags().StringVar(&g.ledger, "ledger", "", "the ledger file (default: in your configuration directory)")
cmd.AddCommand(loginCmd(g), collectionsCmd(g), syncCmd(g), statusCmd(g), historyCmd(g), formatsCmd())
return cmd
}
func loginCmd(g *globalFlags) *cobra.Command {
return &cobra.Command{
Use: "login",
Short: "Save the gateway's address and your key",
Long: `Asks for the gateway's address and your API key, checks them, and saves them
in your configuration directory, readable by you only. The key is typed
without being shown, so it does not end up in your shell's history.`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, _ := loadConfig()
in := bufio.NewReader(os.Stdin)
ask := func(q, def string) string {
if def != "" {
fmt.Fprintf(cmd.OutOrStdout(), "%s [%s]: ", q, def)
} else {
fmt.Fprintf(cmd.OutOrStdout(), "%s: ", q)
}
s, _ := in.ReadString('\n')
if s = strings.TrimSpace(s); s == "" {
return def
}
return s
}
c.Gateway = strings.TrimRight(ask("Gateway address (https://…)", or(g.gateway, c.Gateway)), "/")
fmt.Fprint(cmd.OutOrStdout(), "API key: ")
var key string
if term.IsTerminal(int(os.Stdin.Fd())) {
b, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(cmd.OutOrStdout())
if err != nil {
return err
}
key = strings.TrimSpace(string(b))
} else {
key, _ = in.ReadString('\n')
key = strings.TrimSpace(key)
}
if key != "" {
c.Key = key
}
c.Project = ask("Project, for a personal key (empty for a project's key)", or(g.project, c.Project))
if g.ca != "" {
c.CA = g.ca
}
h, err := kbsync.NewHTTP(c.CA)
if err != nil {
return err
}
cl := &kbsync.Client{Base: c.Gateway, Key: c.Key, Project: c.Project, HTTP: h, Wait: time.Second}
ctx, cancel := context.WithTimeout(cmd.Context(), 30*time.Second)
defer cancel()
cs, err := cl.Collections(ctx)
if err != nil {
return fmt.Errorf("the gateway refused: %w", err)
}
p, err := configPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(p), 0o700); err != nil {
return err
}
b, _ := json.MarshalIndent(c, "", " ")
if err := os.WriteFile(p, b, 0o600); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "Saved in %s. This key reaches %d collection(s); `llmgw-kb collections` lists them.\n", p, len(cs))
return nil
},
}
}
func or(a, b string) string {
if a != "" {
return a
}
return b
}
func collectionsCmd(g *globalFlags) *cobra.Command {
return &cobra.Command{
Use: "collections",
Short: "List the collections your key may read, and which it may add to",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
c, err := client(g)
if err != nil {
return err
}
cs, err := c.Collections(cmd.Context())
if err != nil {
return err
}
if len(cs) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "No collections. An administrator creates them and grants access (llmgw ctl kb).")
return nil
}
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0)
fmt.Fprintln(tw, "NAME\tACCESS\tOWNER\tEMBEDDED BY")
for _, x := range cs {
access := "read"
if x.Writable {
access = "read, write"
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%s (%d)\n", x.Name, access, or(x.OwnerName, x.Owner), x.EmbedModel, x.Dims)
}
return tw.Flush()
},
}
}
func syncCmd(g *globalFlags) *cobra.Command {
var o kbsync.Options
var verbose bool
var ocr string
var maxMiB int64
cmd := &cobra.Command{
Use: "sync COLLECTION PATH...",
Short: "Send a collection what changed in these folders and files",
Long: `Reads the files under each PATH that the collection does not already have as
they are, and sends their text and vectors. Run it again whenever you like;
unchanged files are not even read.
Documents are named by their path under PATH, with forward slashes; with
several PATHs, each folder's own name comes first.
--dry-run reads and cuts everything, sends nothing, and says what would
leave this machine
--delete removes documents this machine sent whose files are gone
(only under these PATHs; nothing anybody else sent)
--retry reads again files skipped before (after installing LibreOffice
or Tesseract, say)
Without --include, every file of a known format is read (llmgw-kb formats);
hidden files, version control folders and Office lock files are not.`,
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client(g)
if err != nil {
return err
}
ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt)
defer stop()
l, err := g.openLedger(ctx)
if err != nil {
return err
}
defer l.Close()
o.Collection, o.Roots = args[0], args[1:]
o.Extract = extract.Options{Helpers: extract.FindHelpers(), OCRLanguages: ocr, MaxBytes: maxMiB << 20}
out := cmd.OutOrStdout()
c.Waiting = func(why string, d time.Duration) {
fmt.Fprintf(out, " .. waiting %s: %s\n", d, why)
}
start := time.Now()
rep, err := kbsync.Sync(ctx, c, l, o, func(e kbsync.Event) {
switch e.Kind {
case "unchanged":
if !verbose {
return
}
fmt.Fprintf(out, " = %s\n", e.Name)
case "sent", "would-send":
fmt.Fprintf(out, " %-8s %s (%d chunks, %s)\n", label(e.Kind), e.Name, e.Chunks, size(e.Bytes))
default:
line := fmt.Sprintf(" %-8s %s", label(e.Kind), e.Name)
if e.Detail != "" {
line += ": " + e.Detail
}
fmt.Fprintln(out, line)
}
})
if rep.Collection.Name != "" {
verb := "sent"
if o.DryRun {
verb = "would send"
}
fmt.Fprintf(out, "\n%s: %d files; %s %d (%d chunks, %s), %d unchanged, %d skipped, %d failed, %d deleted, in %s\n",
rep.Collection.Name, rep.Files, verb, rep.Sent, rep.Chunks, size(rep.Bytes), rep.Unchanged,
rep.Skipped, rep.Failed, rep.Deleted, time.Since(start).Round(time.Second))
}
if err != nil {
return err
}
if rep.Failed > 0 {
return fmt.Errorf("%d document(s) failed; run again to retry them", rep.Failed)
}
return nil
},
}
f := cmd.Flags()
f.StringSliceVar(&o.Include, "include", nil, "read only files matching these globs (\"*.pdf,docs/**/*.md\")")
f.StringSliceVar(&o.Exclude, "exclude", nil, "never read files matching these globs")
f.BoolVar(&o.Hidden, "hidden", false, "read hidden files and folders too")
f.BoolVar(&o.DryRun, "dry-run", false, "send nothing; say what would be sent")
f.BoolVar(&o.Delete, "delete", false, "remove documents this machine sent whose files are gone")
f.BoolVar(&o.Retry, "retry", false, "read again files skipped before")
f.IntVar(&o.Workers, "workers", 4, "files read and sent at once")
f.BoolVar(&o.EmbedHere, "embed-here", false,
"embed on this machine and send the vectors, even to a gateway that embeds text it is sent")
f.StringVar(&ocr, "ocr-languages", "tur+eng", "Tesseract's languages for images and scanned pages")
f.Int64Var(&maxMiB, "max-size", 256, "skip files larger than this many MiB")
f.BoolVarP(&verbose, "verbose", "v", false, "list unchanged files too")
return cmd
}
func label(kind string) string {
switch kind {
case "would-send":
return "send"
case "would-delete":
return "delete"
}
return kind
}
func size(b int64) string {
switch {
case b >= 1<<20:
return fmt.Sprintf("%.1f MiB", float64(b)/(1<<20))
case b >= 1<<10:
return fmt.Sprintf("%.0f KiB", float64(b)/(1<<10))
}
return fmt.Sprintf("%d B", b)
}
func statusCmd(g *globalFlags) *cobra.Command {
var all bool
return &cobra.Command{
Use: "status COLLECTION",
Short: "Show what this machine has sent to a collection, and what it skipped",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
c, err := client(g)
if err != nil {
return err
}
m, err := c.Manifest(cmd.Context(), args[0])
if err != nil {
return err
}
l, err := g.openLedger(cmd.Context())
if err != nil {
return err
}
defer l.Close()
es, err := l.Entries(cmd.Context(), strings.TrimRight(c.Base, "/"), m.Collection.ID)
if err != nil {
return err
}
counts := map[kbsync.State]int{}
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0)
fmt.Fprintln(tw, "STATE\tDOCUMENT\tCHUNKS\tWHEN\tWHY")
for _, e := range es {
counts[e.State]++
if e.State == kbsync.Sent && !all {
continue
}
fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n", e.State, e.Name, e.Chunks, e.At.Format("2006-01-02 15:04"), e.Reason)
}
_ = tw.Flush()
fmt.Fprintf(cmd.OutOrStdout(), "\n%s holds %d documents; from this machine %d sent, %d skipped, %d failed.",
m.Collection.Name, len(m.Documents), counts[kbsync.Sent], counts[kbsync.Skipped], counts[kbsync.Failed])
if !all {
fmt.Fprint(cmd.OutOrStdout(), " (--all lists the sent ones too)")
}
fmt.Fprintln(cmd.OutOrStdout())
return nil
},
}
}
func historyCmd(g *globalFlags) *cobra.Command {
return &cobra.Command{
Use: "history",
Short: "List the latest syncs from this machine",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
l, err := g.openLedger(cmd.Context())
if err != nil {
return err
}
defer l.Close()
runs, err := l.Runs(cmd.Context(), 20)
if err != nil {
return err
}
tw := tabwriter.NewWriter(cmd.OutOrStdout(), 2, 4, 2, ' ', 0)
fmt.Fprintln(tw, "STARTED\tTOOK\tCOLLECTION\tSENT\tUNCHANGED\tSKIPPED\tFAILED\tDELETED")
for _, r := range runs {
took := "unfinished"
if !r.Finished.IsZero() {
took = r.Finished.Sub(r.Started).Round(time.Second).String()
}
fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%d\t%d\t%d\n", r.Started.Format("2006-01-02 15:04"), took,
r.Collection, r.Sent, r.Unchanged, r.Skipped, r.Failed, r.Delete)
}
return tw.Flush()
},
}
}
func formatsCmd() *cobra.Command {
return &cobra.Command{
Use: "formats",
Short: "List the formats read, and the helpers found on this machine",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
builtIn, helper := extract.Supported()
out := cmd.OutOrStdout()
fmt.Fprintf(out, "Read here:\n%s\n\n", wrap(builtIn))
fmt.Fprintf(out, "Read with a helper program:\n%s\n\n", wrap(helper))
fmt.Fprintln(out, "Helpers:")
for _, l := range extract.FindHelpers().Describe() {
fmt.Fprintln(out, " "+l)
}
return nil
},
}
}
func wrap(words []string) string {
var b strings.Builder
line := " "
for _, w := range words {
if len(line)+len(w) > 78 {
b.WriteString(strings.TrimRight(line, " ") + "\n")
line = " "
}
line += w + " "
}
b.WriteString(strings.TrimRight(line, " "))
return b.String()
}
+24
View File
@@ -0,0 +1,24 @@
package main
import (
"os/exec"
"strings"
"testing"
)
// The client goes onto employees' laptops: none of the server, agent, store
// or setup code may come with it, and it must build where llmgw does not.
func TestTheClientCarriesNoServerCode(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").Output()
if err != nil {
t.Skipf("go list: %v", err)
}
for _, dep := range strings.Fields(string(out)) {
for _, banned := range []string{"/internal/store", "/internal/router", "/internal/agent", "/internal/control",
"/internal/cli", "/internal/guard", "github.com/jackc/pgx"} {
if strings.Contains(dep, banned) {
t.Errorf("llmgw-kb depends on %s", dep)
}
}
}
}
+37 -12
View File
@@ -1,4 +1,4 @@
# Two control planes
# More than one control plane
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
@@ -37,7 +37,7 @@ 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
**A load balancer you already run.** Health-check `/leader` 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.
@@ -50,25 +50,50 @@ 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`
## Check `/leader`, 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,
`/health` says the process is up. It says nothing about whether it leads,
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.
from. A standby passes client requests on to the leader, so traffic sent to
one still works — until the leader is the machine that was lost, and there
is nobody to pass them to.
`/leader` is 200 on the control plane that leads and 503 on a standby. It is
what keepalived follows for the floating address, and what a load balancer
should send traffic by: to the one machine that answers 200.
`/ready` is narrower: 200 only when this process has somewhere to send a
request, so 503 on a standby and also on a leader with no model running yet.
Checking it takes the portal and the admin pages away with the models — on a
new cluster with no node yet, nobody can sign in to add one. Use it to stop
sending inference only, if your load balancer can tell the paths apart.
`/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:
During a failover nothing 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.
is not. A proxy that waits that long for a healthy machine rather than
failing at once (Caddy's `lb_try_duration`) hides it from clients.
**Caddy**, in front of three control planes — the end of
[deploy/proxy/Caddyfile](../proxy/Caddyfile) has it with the rest of the
settings:
```
reverse_proxy 10.0.0.157:8080 10.0.0.158:8080 10.0.0.159:8080 {
lb_policy first
health_uri /leader
health_interval 2s
lb_try_duration 15s
flush_interval -1
}
```
With a floating address the address already follows the leader: give Caddy
that one address instead.
Not the admin port, for a different reason: it serves pprof, and pprof can
dump the process heap, where API keys sit in plaintext.
+3 -1
View File
@@ -41,7 +41,9 @@ 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.
# Any number both machines agree on and nothing else on the segment uses:
# another cluster's keepalived with the same one reads as an invalid
# password. `llmgw server add --vip` derives one from the address.
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.
+46 -1
View File
@@ -504,7 +504,7 @@
{
"type": "timeseries",
"title": "GPU busy, by card",
"description": "Share of each sample period a kernel was running, sampled every heartbeat.",
"description": "Share of each sample period in which any kernel ran (nvidia-smi's utilization.gpu), sampled every heartbeat. Not how much of the chip: one SM busy reads 100%. The SMs panel and power against its limit say how much.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
@@ -908,6 +908,51 @@
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU SMs and tensor cores active, by card",
"description": "From the cards' profiling counters: NVML's GPM on Hopper and later, or a dcgm-exporter on the node. SMs active is the share of time the SMs had work, averaged over all of them; the gap to busy is chip a running kernel leaves idle. Tensor cores stay low in decode at batch one; prefill and batches are where they rise. Empty for cards nothing can profile.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 0,
"y": 73,
"w": 24,
"h": 7
},
"id": 25,
"targets": [
{
"expr": "llmgw_gpu_sm_active_percent",
"legendFormat": "{{node}} gpu {{gpu}} SMs",
"refId": "A"
},
{
"expr": "llmgw_gpu_tensor_active_percent",
"legendFormat": "{{node}} gpu {{gpu}} tensor",
"refId": "B"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
}
]
}
+13 -1
View File
@@ -21,11 +21,18 @@ 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.
#
# Every control plane, one line each: the leader moves on restarts and the
# router's metrics are the leading one's, so scraping one alone leaves gaps
# — and alerts on them — whenever another leads.
- job_name: llmgw
static_configs:
- targets: ['10.0.0.140:9091']
labels:
instance: control-plane
instance: control-plane-140
# - targets: ['10.0.0.150:9091']
# labels:
# instance: control-plane-150
# Every machine of the cluster, for its node exporter: network and disk
# traffic, filesystems, CPU time. The control plane lists them — nodes,
@@ -33,10 +40,15 @@ scrape_configs:
# nothing edited here, and each carries its llmgw id as the node label.
# `llmgw node add` installs the exporter; on a machine added before that,
# apt-get install prometheus-node-exporter.
# Asked of every control plane too: a standby lists only the control
# planes and the store, and they label every machine alike, so the same
# target from two of them is scraped once.
- job_name: nodes
http_sd_configs:
- url: http://10.0.0.140:9091/prometheus/targets
refresh_interval: 1m
# - url: http://10.0.0.150:9091/prometheus/targets
# refresh_interval: 1m
- job_name: prometheus
static_configs:
+34
View File
@@ -14,6 +14,7 @@ llm.example.com {
max_size 32MB
}
# One control plane. With more than one, see the end of this file.
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
@@ -39,3 +40,36 @@ llm.example.com {
# Access log to stderr, which under systemd is the journal.
log
}
# More than one control plane (server init --standbys, server add): one of
# them leads, the others stand by. Name them all and send to the one that
# leads — /leader is 200 there and 503 on a standby. Not /ready: that is 503
# on a leader with no model running yet, and would take the portal away with
# it. A standby would pass a request on to the leader anyway, but not while
# there is none, and the hop is for nothing.
#
# With a floating address (--vip) the address already follows the leader:
# give reverse_proxy that one address instead, and keep the rest as above.
#
# llm.example.com {
# request_body {
# max_size 32MB
# }
# reverse_proxy 10.0.0.157:8080 10.0.0.158:8080 10.0.0.159:8080 {
# # The first that is healthy: the leader, whichever it is.
# lb_policy first
# health_uri /leader
# health_interval 2s
# health_timeout 2s
# # While leadership moves — a few seconds when a leader is lost —
# # none is healthy; a request waits that long for one rather than
# # failing at once.
# lb_try_duration 15s
# lb_try_interval 250ms
# flush_interval -1
# transport http {
# response_header_timeout 15m
# }
# }
# log
# }
+3
View File
@@ -2,6 +2,9 @@
Description=llmgw node agent
After=network-online.target
Wants=network-online.target
# Never given up on: a new release that fails its first starts, or an upgrade
# that restarts it twice in a minute, would otherwise leave the node off.
StartLimitIntervalSec=0
[Service]
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
+7 -1
View File
@@ -39,7 +39,7 @@ func (d Doc) Source() string {
// than with either reader, because the console's manual and the gateway's
// docs portal both list them, and two lists drift.
var docOrder = []string{"console", "quick-install", "install", "node-onboarding", "onboarding",
"clients", "guardrails", "choosing-models", "fabrics", "facility-aware-placement", "data",
"clients", "guardrails", "knowledge", "choosing-models", "fabrics", "facility-aware-placement", "data",
"reference", "development"}
// Manual is every document, in reading order: those docOrder names, then any
@@ -96,6 +96,12 @@ func titleOf(src, path string) string {
//go:embed docs/admin-api.openapi.json
var AdminOpenAPI []byte
// ClientOpenAPI is the client API described in OpenAPI 3.1, generated from
// internal/clientapi's route table and served at /v1/openapi.json.
//
//go:embed docs/client-api.openapi.json
var ClientOpenAPI []byte
// EvalSuites are the example quality suites under deploy/eval, which the
// control plane runs by name when its eval directory has none of the name.
//
+16 -7
View File
@@ -8,8 +8,9 @@ description is.
anything they do can be done with `curl`, a generated client or the
[Terraform provider](../terraform-provider-llmgw/README.md). It adds and
removes models, scales them, cordons and upgrades nodes, sets capacity
quotas, attaches guardrail policies, and reads the fleet, usage and the audit
log.
quotas, attaches guardrail policies, adds organizations, teams and projects,
issues and revokes API keys, puts people in the tree, and reads the fleet,
usage and the audit log.
## Reaching it
@@ -99,6 +100,14 @@ curl -s "${H[@]}" -X POST $API/models/qwen-small/scale -d '{"min": 0, "max": 2,
# A team's capacity quota.
curl -s "${H[@]}" -X PUT $API/quotas -d '{"scope": "team:Acme/ml", "limit": {"gpus": 8}}'
# An organization, a team in it and a project in that; then a key for the
# project. The answer holds the key once: only its hash is kept.
curl -s "${H[@]}" -X POST $API/orgs -d '{"name": "Acme"}'
curl -s "${H[@]}" -X POST $API/teams -d '{"name": "ml", "in": "Acme"}'
curl -s "${H[@]}" -X POST $API/projects -d '{"name": "search", "in": "Acme/ml"}'
curl -s "${H[@]}" -X POST $API/keys -d '{"name": "ci", "project": "Acme/ml/search", "expires_in": "720h"}'
curl -s "${H[@]}" -X POST $API/users/ayse@example.com/memberships -d '{"target": "team:Acme/ml"}'
# Remove the model; a DELETE carries the content type too.
curl -s "${H[@]}" -X DELETE $API/models/qwen-small
```
@@ -149,11 +158,11 @@ go generate ./internal/docgen
## What it does not cover
- **Organizations, teams, projects, people and API keys.** These live in the
store and are managed with `llmgw org`, `llmgw users` and `llmgw keys` on the
control plane, or in the web console's `/admin` pages on the client port
behind a session. The admin API reads the tree only where a quota or a
policy names a part of it.
- **Making people's accounts.** People sign up, are invited, or come from
the identity provider (OIDC, SCIM); the API lists them and puts them in
the tree, and `llmgw users` approves, disables and deletes them. Key
lifetime limits and spending limits are `llmgw keys ttl` and `llmgw keys
limits`.
- **The client-facing API** (`/v1/chat/completions` and the rest), which is
OpenAI's and Anthropic's, on `listen`, with API keys.
- **The rest of the admin listener**: `/metrics`, `/health`, the dashboard
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+78 -2
View File
@@ -38,6 +38,14 @@ export ANTHROPIC_BASE_URL=https://llm.example.com
export ANTHROPIC_API_KEY=$LLMGW_API_KEY
```
### The API described
Every endpoint is described in OpenAPI 3.1 at `/v1/openapi.json`. It needs
no key, so a client generator can be pointed at it. `/docs/api` shows it in
Swagger UI, where *Authorize* takes a key and *Try it out* sends a request to
the gateway. People with a part in running the gateway also find the admin
API's document there.
## A first request
### curl
@@ -526,6 +534,73 @@ curl https://llm.example.com/mcp \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
```
**Pictures, 3D models, vision and workflows.** Where the gateway has the
models for them, the same server offers more tools — only those it has a
model for:
| Tool | Does |
|---|---|
| `generate_image` | A picture from a prompt. `style` adds what a picture for a mesh wants: `concept` (one object, three-quarter view, grey background), `side` (an exact side view), `texture` (a seamless tile) or `none`. Answers the PNG's `file_id`, `url` and `seed`, with a 512 px preview to look at. |
| `generate_mesh` | A GLB from a picture — `image` is a `file-…` id (a `generate_image` result, say), a data URL or base64 — or from a `prompt`, for which a concept picture is made first. `seed`, `resolution`, `texture_resolution`, and any other input the model's workflow declares in `inputs`. Answers the GLB's `file_id`, `url` and licence. |
| `review_images` | Asks a vision model about one to four pictures (each shrunk to 1280 px on its long side); `json_schema` makes it answer that JSON. |
| `run_workflow` | Runs a ComfyUI workflow from the inputs its catalog entry declares, and answers its result file. |
| `job_status` | Where a mesh or a workflow run stands, and its file when done; `wait_seconds` waits for it. |
| `gateway_status` | The picture, 3D, vision and workflow models, whether each is loaded on a GPU now, a workflow's inputs, and your last jobs. |
Each tool makes its request of the gateway under your key, so your
allowlist, limits, left-out licences and budget apply, and it is charged
and listed like any other request. What it makes is kept as your file for
as long as job results are: fetch it from `url` with the same key (`curl -H
"Authorization: Bearer $LLMGW_API_KEY" -o rock.glb <url>`). A tool server
over HTTP cannot write to your disk, so an agent that needs the file
locally — to import it in Blender — fetches it, or goes through a small
local bridge that does.
A picture can be uploaded once and named by its id after: `POST /v1/files`
with `purpose=vision` (or `user_data`) keeps it, for as long as job results
are kept, as a file of yours that `generate_mesh`, `review_images` and a
workflow's file inputs take as `file-…`. A call is at most 64 MB.
A model that is not loaded is waited for: a picture or a look at one whose
model is starting, or waiting for another to give up the GPU, waits as a
mesh does, saying so, rather than failing with "model loading".
A mesh takes from half a minute to a few minutes. A client that takes the
answer as a stream (`Accept: text/event-stream`, as Claude Code does) is
answered that way: `notifications/progress` for the call's
`progressToken` while it runs — queued, waiting for the GPU, the node and
its percent — then the result, and the call waits up to ten minutes. One
answered in one piece waits 50 seconds; a job not done by then is answered
with its `job_id`, to ask after with `job_status`. `wait_seconds` (up to
900) sets either.
```
claude mcp add --transport http llmgw https://llm.example.com/mcp \
--header "Authorization: Bearer $LLMGW_API_KEY"
```
**Your company's tools.** Where an administrator has registered the
company's own MCP servers (Jira, a file server, a database) and your key is
granted their tools, the same `/mcp` lists them too, each as `server.tool`
(`jira.search`). Give your agent only llmgw's `/mcp` and your key: the
servers' own credentials stay with llmgw. A tool your key is not granted is
not listed, and calling it by name is refused. Each call is in the audit
log under your key: which tool, the size of what was sent, how it ended and
how long it took, never what it said.
### Licences you leave out
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
it allows commercial use and the regions it grants nothing in). On your
profile page, under **Licences**, you can leave licences out: one by one,
every licence that forbids commercial use, or every licence that grants
nothing in a region you name (`EU` for the union). The rules cover models
added later too. From then on those models are not listed to your keys and
a request for one is refused with 403 and the code `licence_excluded`,
naming the choice that left it out — on every key you own and on the
portal's pages, whatever a key's own allowlist says. A change reaches keys
already in use within a minute.
### Headers llmgw sends back
| Header | Says |
@@ -545,9 +620,10 @@ its usual exception with the gateway's message in it. The ones worth knowing:
|---|---|
| 400 | Besides a malformed request: a prompt too long for the model, or a guardrail policy refused it (the code `content_policy_violation`). |
| 401 | No key, a mistyped one, or one that was revoked or has expired. |
| 403 | The key may not use that model. |
| 403 | The key may not use that model; with the code `licence_excluded`, its licence is one you left out on your profile page. |
| 429 | A rate limit or budget of the key, its project or its team was reached. `Retry-After` says when to try again, and is absent when waiting will not help — a spent monthly budget. |
| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the request waited as long as it may. The SDKs retry by themselves; a name that is not served will not start to be. |
| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the queue is full. The SDKs retry by themselves; a name that is not served will not start to be. |
| 504 | Every replica was busy for as long as the key's band may wait. `Retry-After` says when to send it again. |
## Priority
+99 -14
View File
@@ -2,8 +2,8 @@
`sudo llmgw`, with nothing after it, on a machine llmgw runs on. On a machine
with nothing installed it opens the setup screens; on a control plane, the
console described here; on a node, the node's own console, which edits
`agent.yaml`. Everything on these screens can also be done by command — each
console described here; on a node, the node's own console, which shows
what runs on that machine and edits `agent.yaml`. Everything on these screens can also be done by command — each
screen says which — so a script never needs them and a person never needs
to remember the commands.
@@ -67,7 +67,8 @@ or with the mirror unticked, is said in the form and nothing starts.
## Watch
**Overview** is `llmgw ctl status`: nodes, GPUs by class, fabrics, replicas,
models, the store's members, and anything parked, unplaced or idle.
models, the store's members, and anything parked, unplaced or idle. With no
webhook in the cluster's settings it says the alerts go nowhere.
**Monitor** draws the cluster the way btop draws a machine: CPU, memory,
GPU busy and GPU memory, requests and tokens a second, time to first token,
@@ -164,6 +165,32 @@ which): requests, tokens, errors and spend.
team or project is guaranteed and may at most hold, and what it holds now.
**A** adds one; on a row, change or remove it.
**Knowledge** lists the knowledge-base collections: each one's owner, the
model that embeds it, how many documents and chunks it holds and who else it
is granted to. **A** makes one. On a row: its **Documents**, **Grant…** a
project, team, organization, person or key read or write access, **Revoke**
a grant, **Answer from it by default…** for a part of the tree, **Try a
search…** as an administrator, or **Delete…** it. People
fill collections from their own machines with `llmgw-kb`
([knowledge.md](knowledge.md)). Off on a SQLite store, and says why.
**MCP servers** lists the company's own MCP servers that agents reach through
the gateway's `/mcp`, their tools named `server.tool`: whether each is on,
who has been granted its tools, its rate per caller, the names of the
credentials it is sent (never their values) and its address. **A** adds one:
its name, which is its tools' prefix, its address, and a bearer token or
another header, kept sealed in the store. On a row: **Test, and list its
tools**, with its credential, as a call would; **Grant its tools to…** a
team, project, organization, person or key — all of them, `jira.*`, or some
by name — beside whatever else that level is granted, and saying when a
level above keeps them out of reach; **Revoke** a grant;
**Change…** its address, timeout or rate; **Replace its credential…**;
**Turn off** (its tools are listed nowhere and every call refused) or on;
**Remove…**. A server's tools are seen nowhere until granted. The screen
also says how a person points an agent at the gateway, with a key of their
own from `/profile/keys`. `llmgw ctl mcp` does the same from a shell, and
the web page **MCP servers** (`/admin/mcp`, administrators only) in a browser.
**Mirror** is what the model mirror holds, one row for each version of each
model — a file, or the set of files a model of several is: shards, a vision
projector, a config and tokenizer — with how far its download has got and
@@ -181,6 +208,19 @@ starting — and whether it ended done, failed, or waiting for room on busy
cards. The entry shows how many are running. Enter on one shows its steps.
`llmgw ctl jobs` is the same, and `llmgw ctl jobs ID` follows one.
**Slurm** is the Slurm cluster run beside llmgw, when there is one: its
controller and whether slurmctld runs, and each node that runs slurmd —
whose it is now (llmgw's, Slurm's, or on its way between them), its state in
Slurm, the jobs running there. On a row, **Lend to Slurm…** moves the node's
replicas off and then lets Slurm run jobs there; **Take back from Slurm…**
starts no new job there and gives it back to llmgw when its jobs have ended.
Both change `slurm.lent` in the cluster's settings, as `llmgw ctl slurm lend`
and `reclaim` do. Turning Slurm on and choosing its nodes is `llmgw ctl slurm
setup`, or the Slurm part of the first install. Above the nodes it says where
the shared files come from and how many users llmgw made; the SHARED column
is each node's (mounted, exported, missing). `llmgw ctl slurm shared` and
`llmgw ctl slurm users` change them.
**Logs** shows a node's agent log, newest at the bottom, errors and warnings
coloured.
@@ -194,7 +234,8 @@ and change it with the same commands as `llmgw users`, `llmgw keys` and
dashboard, and what they belong to. **A** creates an account, already
approved. On a row: approve one waiting, suspend or reinstate, give or take
the cluster dashboard, add to or take out of an organization, team or
project, their limits, a new password (their sessions close), delete, and
project (on an organization as a member, its manager, who sees its page, or
its admin, who also changes it), their limits, a new password (their sessions close), delete, and
**Forget them…** for a request to be forgotten (`llmgw users forget`).
**API keys** — every key, its project, owner, state and last use. **A**
@@ -237,6 +278,28 @@ long a key made there may live, and is the expiry of one made without any
Limits are requests and tokens a minute, a budget per day or month, and the
models allowed. Each level only narrows what is above it.
**Sign-in** — how people get in besides a password kept here: single
sign-on, Active Directory and provisioning, each on or off, with the
directory's last sync (how many people, accounts kept in step, suspended,
reinstated).
- **E** sets up Entra ID. Give the tenant ID, the client ID and secret,
and the groups for administrators and operators. A provider other than
Entra takes its issuer URL in place of the tenant. The form shows the
redirect address to register.
- **D** sets up Active Directory: the servers, where people are, the
service account and its password, the domain's CA certificate as a PEM
file on this machine, the group whose members may sign in, and groups
for roles and teams.
- Before it is kept, the directory is tried from the control plane: each
server reached and bound, how many people may sign in, how many are in
each group. If any step fails, nothing is kept and the steps are shown.
**T** tries the settings in force.
- On the provisioning row, Enter makes a SCIM token, shown once.
- The password, the client secret and the token are kept in the store,
sealed by the master key when one is set. The settings name them as
`store:auth/…`, so each control plane has them with no file put on it.
The CA is written into the settings themselves.
**Guardrails…** on a row lists the guardrail policies server.yaml defines.
Each policy is marked in one of three ways:
@@ -270,14 +333,20 @@ page's text.
## Settings
**server.yaml** (on a node, **agent.yaml**) is every key of the file, as a
tree of menus built from the configuration reference: the value set, or the
one that applies when nothing is, its type, and what it is for. Enter changes
a value or opens a block; on a list or map of blocks, Enter opens an entry and
**D** removes one. **U** puts a key back to its default. **S** saves: the file
is checked the way the service reads it before anything is written, and its
comments are kept. The service reads it when it starts, so the console offers
to restart it. Another control plane keeps its own copy.
**server.yaml** (on a node, **agent.yaml**) is every key, as a tree of menus
built from the configuration reference: the value set, or the one that
applies when nothing is, its type, and what it is for. Enter changes a value
or opens a block; on a list or map of blocks, Enter opens an entry and **D**
removes one. **U** puts a key back to its default. **S** saves.
On a control plane, the cluster's keys are read from and saved to the
cluster's settings in the store — the same on every control plane, in force
on all of them within seconds (`llmgw ctl config` does the same from a
shell) — and this machine's own keys (listeners, certificates, the store's
address) to its server.yaml, which the server notices and applies by itself.
Either is checked before anything is kept, and the file's comments survive.
On a node, agent.yaml is read when the agent starts, so the console offers
to restart it.
## Do
@@ -313,9 +382,25 @@ to restart it. Another control plane keeps its own copy.
- **Restart all replicas**, one at a time.
- **Back up now** — the store and `/etc/llmgw` into one archive
(`sudo llmgw server backup`).
- **Wipe this machine** — removes llmgw's services, configuration, state and
users from this machine, after listing them and asking for its name
(`sudo llmgw wipe`; on a node too).
- **Leave**.
## The node's console
On a node, `sudo llmgw` opens `agent.yaml` in the same settings view, and the
manual. The cluster is watched and changed from a control plane.
On a node, `sudo llmgw` opens on **Overview**: what this machine is doing, read
on the machine itself, so it works without an admin token and while the control
plane is out of reach. Refreshed every three seconds, it shows
- llmgw-agent's state and since when, and the version of llmgw;
- each control plane the agent dials, from `agent.yaml` and the list the
leader last sent, and whether it can be reached;
- each GPU: how busy, its memory, its heat, and the models on it;
- the engines running here, from the agent's own records: replica, model,
engine, GPUs, port, whether the process is still there, and for how long;
- the free space where weights and engines are kept;
- the agent's warnings and errors of the last hour.
Below it, `agent.yaml` in the same settings view, and the manual. The cluster
is watched and changed from a control plane.
+3 -1
View File
@@ -72,7 +72,9 @@ each answer that may be reused, as the client got it, and a hash of the
request it answers, in the gateway process's memory — never in the store,
never on disk — for the project's TTL or until pushed out by newer ones
(`router.cache.max_bytes`), and gone with a restart. Never under a `no_store`
policy. Forgetting a person does not reach into it; the answers age out by
policy. A project that reuses answers to similar questions (`--similar`)
also has each question's last user message embedded, and the vector kept
beside its answer, in the same memory and for as long. Forgetting a person does not reach into it; the answers age out by
the TTL. The store keeps only the setting, and a usage record's mark that it
was answered from the cache.
+187 -19
View File
@@ -4,13 +4,30 @@ Written for the operator deciding what the gateway lets through: what a
policy can check, what it can do, and how one is attached to a team.
`llmgw server init` writes a `baseline` policy that applies to every
request, with Llama Guard 3 1B as its judge on a CPU; see
[install.md](install.md#2-the-control-plane) for what it checks. Without a
`guard:` section in `server.yaml` nothing is checked and nothing costs
anything. With one, every request is judged once it is authorised and
request, with Qwen3Guard-Gen 0.6B as its judge on CPUs; see
[install.md](install.md#2-the-control-plane) for what it checks. The
policies are part of the cluster's settings, kept in the store: `llmgw ctl
config edit` (or the console's Settings) changes them, and the change is in
force on every control plane within seconds, without a restart. A cluster
whose settings were brought in from a server.yaml without a `guard:` section
gets the `baseline` policy, without a judge. With no policies, nothing is
checked and nothing costs anything. With one, every request is judged once it is authorised and
within its limits, with its stored conversation spliced in, and before it is
sent to a model; and the answer is judged before the client sees it.
## Turning them off and on
```
llmgw ctl guard off --note "why" # no request is checked, whatever the policies say
llmgw ctl guard on # every policy checks again, as it was
```
That is `guard.enabled` in the cluster's settings, in force on every control
plane within seconds without a restart. The policies and where they are
attached are kept. While off, `llmgw ctl status`, `llmgw ctl guard list` and
the guardrails page say so. To stop one policy rather than all, give it
`mode: off`, or `mode: shadow` to have it record what it would do.
## A policy
```yaml
@@ -74,6 +91,21 @@ guard:
value is the same placeholder throughout a request).
- **`block`** stops it.
**Putting them back.** With `restore: true` in a policy's `input`, the
values a request had redacted are put back in its answer: the model reads
"write to [PERSON_1] at [EMAIL_1]", the client reads the name and address
it sent. Streamed answers too — a placeholder split across two events is
held until it is whole. Only that request's own values, only into its
answer; the map is kept in memory for the request and nowhere else. The
output rules then read the answer as the client will, so a policy that
redacts personal data in answers still does.
```yaml
input:
pii: {email: redact, phone: redact}
restore: true
```
**Shapes.**
- `system_prompt` is put first.
@@ -97,44 +129,136 @@ guard:
## A judge model
Patterns find what has a shape. For what does not — violence, self-harm,
abuse, whatever a moderation model was trained on — a policy can have a
**judge model on the cluster** read the text:
Patterns find what has a shape. For what does not — violence, crime,
sexual content, self-harm, jailbreaks, whatever a moderation model was
trained on — a policy can have a **judge model on the cluster** read the
text.
The judge must be in the catalog under the name the policy gives, as a
judge (`role: guard`), with a replica ready. When it is not, what the
policy judges is refused with `guard_unavailable` (`fail: closed`) or served
unjudged (`fail: open`), and `llmgw ctl status` and the console's Overview
say which judge and why.
### Which judge
| judge | runs on | size | |
|---|---|---|---|
| `qwen3guard-0.6b` | CPUs | 0.8 GB | the default: about 2 s a check on two threads |
| `qwen3guard-4b` | a GPU | 2.5 GB | for a cluster with a card to spare |
| `qwen3guard-8b` | a GPU | 4.7 GB | |
| `llama-guard-3-1b` | CPUs | 0.9 GB | the earlier default: refuses ordinary coding requests |
| `llama-guard-3-8b` | a GPU | 4.6 GB | |
Measured on the reference cluster, as the judge's probability of "unsafe"
for ordinary coding requests against harmful ones, Llama Guard 3 1B could
not tell them apart: "continue" 0.51, "continue the code above" 0.89, "how
do I kill all child processes in bash" 0.92, against 0.97 to 1.00 for
harmful requests, at any quantisation. Qwen3Guard-Gen 0.6B gave the
ordinary ones 0.15 at the most and the harmful ones 1.00. Its verdicts are
safe, unsafe and **controversial**; controversial counts as safe unless the
policy says `controversial: unsafe`.
A policy that names one of these is enough: the leader fetches its weights
into the mirror and serves it as a judge, and serves it again if it is
removed from the catalog. Another judge is served by hand with `--repo`.
```
llmgw ctl guard judge status # each judge the policies ask, and what is wrong with it
llmgw ctl guard judge enable # Qwen3Guard-Gen 0.6B on CPUs, or the judge the policies ask that is missing
llmgw ctl guard judge enable --gpu # Qwen3Guard-Gen 4B on a GPU
llmgw ctl guard judge use qwen3guard-4b qwen3guard-0.6b # the order every policy asks them in
llmgw ctl guard judge enable --replace # when a model of that name is there, but not as a judge
```
or **Do → Serve the guardrails' judge** in the console.
### A GPU and CPUs together
`judge.models` is a list asked in turn: the first judge that answers gives
the verdict. The next is asked when one has no replica serving, fails, or
does not answer within `latency_budget`; a judge with no replica at all is
passed over at once. A judge on a GPU first and one on CPUs after it is
the GPU's speed while it has its card, and the CPUs' when it does not.
```yaml
judge:
models: [qwen3guard-4b, qwen3guard-0.6b]
input: block
output: block
latency_budget: 5s # each judge's wait
```
A judge on a GPU is served as a `standard` model, on one card: it takes a
card only from what may give one up, and waits while every card is busy,
the judge on CPUs standing in. A judge on CPUs is `critical`, with two
replicas, because every judged request waits on one. `llmgw ctl guard judge
status` says, for each judge, which is asked when it gives no verdict.
### When it refuses ordinary requests
A refusal by a judge names the hazard it saw — the client is told `refused
by the gateway's policy (judge: Violent)` — and the same is kept with the
request: `llmgw ctl request <id>`, the server's log (`guard_rule`) and the
guardrails page show it. To see what the judge says of any text, without
anything being kept:
```
llmgw ctl guard judge ask 'How do I sort a list in Python?'
# qwen3guard-0.6b: safe, 1900 ms
# it answered: "Safety: Safe\nCategories: None"
```
A judge that says `unsafe` to ordinary text is usually Llama Guard 3 1B
(`llmgw ctl guard judge use qwen3guard-0.6b`), the wrong weights (another
repository's, without the judge's template: `judge enable --replace` serves
the right ones), or a policy counting every category: give its judge
`categories`. The baseline counts Violent, Non-violent Illegal Acts, Sexual
Content or Sexual Acts, Suicide & Self-Harm and Jailbreak; PII is the
patterns', and copyright, unethical acts and politically sensitive topics
refuse too much ordinary work. While finding out, `judge.input: flag`
records instead of refusing.
A judge's file served from the mirror page — Llama Guard, Qwen3Guard,
ShieldGemma — is served as a judge too, under the name the policies ask.
By hand, the catalog entry is:
```yaml
# a catalog model (server.yaml models:, or ctl model add)
- name: llama-guard-3-1b
source: http://mirror:8081/llama-guard-3-1b/…/Llama-Guard-3-1B-Q4_K_M.gguf
- name: qwen3guard-0.6b
source: http://mirror:8081/qwen3guard-0.6b/…/Qwen3Guard-Gen-0.6B.Q8_0.gguf
role: guard
guard:
format: llamaguard3 # llamaguard3, shieldgemma or json
categories: [Violent Crimes, Non-Violent Crimes, Sex-Related Crimes, …] # names for S1, S2, …
format: qwen3guard # qwen3guard, llamaguard3, shieldgemma or json
scaling: {min: 2, max: 2}
profiles: [{name: cpu, engine: llamacpp}]
# in a guard policy
judge:
model: llama-guard-3-1b
models: [qwen3guard-0.6b] # or model: one judge
input: block # flag or block; empty does not ask about input
output: flag
categories: [S1, S10] # only these count; empty: any unsafe verdict
latency_budget: 250ms # then the policy's fail: open serves (flagged), closed refuses
categories: [Violent, Jailbreak] # only these count; empty: any unsafe verdict
controversial: safe # or unsafe
latency_budget: 250ms # each judge's wait; after the last, fail: open serves (flagged), closed refuses
```
A model with `role: guard` is the guardrails' alone:
- it is not in `/v1/models` or the chat page, and is refused (404) to
every key, as a name and as a fallback;
- it is `critical` by default and must always run — on demand is refused.
Give it two replicas on different nodes, because every judged request
waits on it.
- it must always run — on demand is refused. One on CPUs is `critical`
and should have two replicas on different nodes, because every judged
request waits on it.
The gateway's calls to it go past admission, accounting and the guardrails
themselves.
- **Formats:**
- `llamaguard3` reads Meta's "safe" / "unsafe
S1,S10".
- `qwen3guard` asks in Qwen3Guard's own words and reads "Safety: Safe |
Unsafe | Controversial" and its "Categories:".
- `llamaguard3` reads Meta's "safe" / "unsafe\nS1,S10"; give the catalog
entry `categories` to have S1 recorded as its name.
- `shieldgemma` asks Google's model whether the text breaks the listed
principles (`categories`, each a sentence) and reads Yes or No.
- `json` expects `{"safe": false, "categories": [...]}`.
@@ -144,8 +268,52 @@ S1,S10".
is read every 600 characters and at the end, so text between two
readings has gone out already; `stream: buffer_all` holds it until it
is read.
- **A long text** is read by its start and its end (about 6000
characters), so a message with a file pasted into it fits the judge's
context rather than overflowing it and going unjudged.
- **Cache:** the same text is not asked about twice within five minutes.
## An outside service
A site required to use a particular guardrail — Azure Content Safety,
Lakera Guard, NeMo Guardrails, a classifier of its own — names it in a
policy's `services`. It is asked over HTTP beside the patterns and before
the judge; its "unsafe" blocks or flags like theirs, and a service that does
not answer is what the policy's `fail` says.
```yaml
policies:
strict:
fail: closed
services:
- name: lakera
url: https://api.lakera.ai/v2/guard
header_files: {Authorization: /etc/llmgw/secrets/lakera} # "Bearer …", read each call
body: '{"messages": [{"role": "user", "content": {{json .Text}}}]}'
blocked: flagged # where the answer says unsafe
reason: breakdown.0.detector_type
on: both # input, output or both
action: block # or flag
timeout: 2s
```
`body` is a template given `.Text` (`{{json .Text}}` writes it as a JSON
string; empty sends `{"input": …}`). `blocked` and `reason` are dotted paths
into the JSON answer, numbers for list items; true, a number above zero, a
non-empty list or a string other than "false" is unsafe. On input the
service reads the latest user turn as the model will, with what `redact`
took out still out; on output the whole answer, once, at its end, as the
client reads it — with `restore`, the values put back. Being at the end, a
service can stop a streamed answer only where it is held back
(`stream: buffer_all`): released a sentence at a time, the client has read
it by then, and what the service finds ends the stream with
`content_filter`, flags it and is recorded. The rule recorded is
`service:<name>:<reason>`. The service is told the text only, never who
sent it. Under `fail: open`, the default, a service that cannot be asked
lets the text through — and a caller can make it fail, sending what it
refuses to read; give `fail: closed` where the service must decide. Of an
answer asked for with `n` above 1, the service reads the first.
## Where a policy applies
`guard.default` applies everywhere. Others are attached to a part of the
+1274 -40
View File
File diff suppressed because it is too large. Load diff
+428
View File
@@ -0,0 +1,428 @@
# Knowledge bases
A knowledge base is a collection of a company's documents that the cluster
can answer from. People put their documents into it from their own machines
with `llmgw-kb`. The tool reads the files on that machine, cuts them into
passages ("chunks") and sends their text; the gateway embeds each passage
with the person's own key and keeps the text and the vectors. The files
themselves never leave the machine.
## Why retrieval, and not training
Retrieval is the tool for *knowledge*: policies, procedures, contracts. The
answer can name its source, a changed document is sent again in minutes, and
a passage only ever reaches people allowed to read its collection.
Training a model on the documents (LoRA) changes how it *writes*, not what it
reliably *knows*. It has no access control: whatever goes into the weights
is available to everyone who may use them.
## What it needs
- **The PostgreSQL store with pgvector.** On a SQLite store knowledge bases
are off, and `llmgw ctl kb list` and the console say why.
- `llmgw server init` and the high-availability database setup install
pgvector and enable it.
- A cluster set up before this build needs it by hand, once:
1. Install the package on every database machine:
`sudo apt-get install postgresql-16-pgvector` (use the server's
version number).
2. On the leader, enable it in llmgw's database:
`sudo -u postgres psql -d llmgw -c 'CREATE EXTENSION vector'`.
3. Restart the control planes: `llmgw restart`.
- **An embedding model in the catalog**, with `capabilities.embeddings`.
Two good choices handle Turkish and run on CPUs: `BAAI/bge-m3` (1024
dimensions) and `Qwen/Qwen3-Embedding-0.6B`.
Add it as any model is added (**Serve a model** in the console, or the
catalog), with its entry saying that it embeds:
```yaml
capabilities:
embeddings: true
```
A collection stays with the model it was made with: vectors from two
models cannot be compared.
Some embedding models are trained with an instruction before the
question and none before the documents. Give it as the entry's
`query_prefix`; llmgw puts it before questions only. For
Qwen3-Embedding:
```yaml
query_prefix: "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: "
```
With pgvector 0.7 or later a collection's index is kept at half
precision (`halfvec`), which halves its memory and takes models of up to
4000 dimensions; with an older pgvector the limit is 2000. A collection
keeps the kind it was made with.
## For administrators
### Make a collection
A collection belongs to one part of the tenancy tree: an organization, a
team, a project or a person. Whoever is in the owner, at any level below it,
may read it and add to it.
```sh
llmgw ctl kb create team:Acme/hr policies bge-m3
llmgw ctl kb create user:USER-ID notes bge-m3 --org Acme
```
The model's dimension is asked of the model itself, so the model has to be
able to serve when the collection is made. `--dims 1024` gives it instead.
In the console: **Knowledge**, then **A**.
### Who may read and add
The owner, and everything below it, may read and add. A **grant** gives the
same to another part of the same organization, to a person, or to one key:
```sh
llmgw ctl kb grant Acme/policies project:Acme/sales/crm # read
llmgw ctl kb grant Acme/policies team:Acme/legal --write # read and add
llmgw ctl kb revoke Acme/policies project:Acme/sales/crm
```
How access works:
- **Grants add up down the tree.** A grant to a team reaches its projects
and their keys.
- **Nothing crosses organizations.** A grant to another organization's
team, project, key or person is refused.
- **A person's own key** (a key with no project) reads nothing by itself.
The person names the project they act in (`llmgw-kb login` asks for it,
or `--project`) and must be a member of it. Joining a second team
therefore does not quietly widen what every key of theirs can read.
- **No access looks like no collection.** A collection a key may not read
is answered exactly as one that does not exist, so even its name does not
leak.
### Answer from collections by default
Clients that cannot add a header still get answers from the documents when
their part of the tree has a default:
```sh
llmgw ctl kb default team:Acme/hr Acme/policies Acme/forms
llmgw ctl kb default key:KEY-ID Acme/policies
llmgw ctl kb default team:Acme/hr --clear
```
- **Which default applies.** The narrowest one: a key's over its project's,
a project's over its team's, a team's over its organization's.
- **A request can override it.** A request that names collections uses
those instead, and `X-Llmgw-Knowledge: none` turns the default off for
that request.
- **A default never refuses a request.** A collection the caller may not
read is left out for them. If the search fails (the embedding model is
down, say), the request is answered without passages and the failure is
logged.
- **Changes take effect within 30 seconds.**
In the console: **Knowledge**, Enter on a collection, then **Answer from it
by default…**.
### Look after it
```sh
llmgw ctl kb list # collections, owners, documents, grants
llmgw ctl kb docs Acme/policies # its documents, who added each and when
llmgw ctl kb rm Acme/policies old/form.pdf # one document out
llmgw ctl kb search Acme/policies how many days of annual leave
llmgw ctl kb delete Acme/policies # the collection, its documents and grants
```
Rules that apply:
- A project, team or organization that owns a collection is not deleted
until its collections are. This is the same rule as for one with members.
- A person's own collections go with their account (`users delete`,
`users forget`).
- Every document added or removed is in the audit log (`llmgw ctl audit`),
by name. The text is never recorded.
### A large import
A few thousand documents ask the embedding model for far more than it can
answer at once. Give the key that sends them a band of its own, below the
people's, that may wait for room for minutes rather than the 30 seconds the
others wait:
```sh
llmgw ctl config set router.admission.bands '[{name: gold, priority: 300, saturation_ceiling: 0.85},
{name: bronze, priority: 100, saturation_ceiling: 0.3}, {name: bulk, priority: 50, saturation_ceiling: 0.3, queue_ttl: 5m}]'
llmgw keys create import --project Acme/docs --band bulk
```
The people's questions then go first, and the import waits behind them. The
gateway embeds a document's passages 32 at a time, four batches at once,
enough to fill an embedding replica's slots; a batch told to wait (`Retry-After`)
waits up to five minutes before the document is answered the same, and
`llmgw-kb` sends it again rather than counting it as failed. It gives up
after 15 minutes.
## For everyone: `llmgw-kb`
### Get it
Download `llmgw-kb` for your system from the release page, next to `llmgw`:
| System | File |
|---|---|
| Windows (Intel/AMD) | `llmgw-kb-VERSION-windows-amd64.exe` |
| Windows (ARM) | `llmgw-kb-VERSION-windows-arm64.exe` |
| macOS (Apple silicon) | `llmgw-kb-VERSION-darwin-arm64` |
| macOS (Intel) | `llmgw-kb-VERSION-darwin-amd64` |
| Linux | `llmgw-kb-VERSION-linux-amd64` / `-arm64` |
Rename it to `llmgw-kb` (`llmgw-kb.exe` on Windows) and put it somewhere on
your PATH. You do not need to install anything else.
On macOS, a downloaded program that is not signed is stopped the first time.
Either right-click it and choose Open, or run
`xattr -d com.apple.quarantine llmgw-kb`.
### Log in once
```sh
llmgw-kb login
```
It asks for three things, checks them, and saves them where only you can
read them:
- **The gateway's address.**
- **Your API key.** It is typed without being shown.
- **A project.** Needed only for a personal key.
Instead of logging in you can set `LLMGW_URL`, `LLMGW_API_KEY` and
`LLMGW_PROJECT`.
```sh
llmgw-kb collections # what your key may read, and which you may add to
```
### Send your documents
```sh
llmgw-kb sync policies ~/Documents/HR
llmgw-kb sync policies "C:\Users\ayse\Documents\İK" --include "*.pdf,*.docx"
llmgw-kb sync policies ./handbook ./forms --exclude "drafts/**"
```
Run it as often as you like. The first run sends everything. Later runs
send only what changed, and do not even read files whose size and time have
not changed.
| Option | What it does |
|---|---|
| `--dry-run` | Reads and cuts everything, sends nothing, and lists what would leave the machine and how much. |
| `--delete` | Also removes documents whose files are gone. Only those this machine sent, from these folders; never anything someone else sent. |
| `--retry` | Reads again files skipped before, for example after installing LibreOffice. |
| `--include`, `--exclude` | Glob patterns. `*.pdf` matches a file name; `reports/**/*.xlsx` matches a path. |
| `--hidden` | Reads hidden files and folders too (left out by default). |
| `-v` | Lists unchanged files too. |
| `--embed-here` | Embeds the passages from this machine and sends the vectors. With a gateway that does not embed them itself it does so anyway. |
Documents are named by their path under the folder, with forward slashes,
on every system. With several folders, each folder's own name comes first.
```sh
llmgw-kb status policies # what this machine sent and skipped, and why
llmgw-kb history # the latest runs
llmgw-kb formats # what can be read here
```
### What it reads
| Read here | Formats |
|---|---|
| Documents | PDF (text), Word (`.docx`), Excel (`.xlsx`), PowerPoint (`.pptx`, with speaker notes), OpenDocument (`.odt`, `.ods`, `.odp`), RTF, EPUB |
| Web and mail | HTML, MHTML, e-mail (`.eml`, with its attachments) |
| Text | Markdown, plain text, CSV, JSON, YAML, XML, subtitles, notebooks (`.ipynb`), source code |
| Archives | ZIP, with every file inside it read in turn |
| Read with a helper on your machine | Helper |
|---|---|
| Word, Excel and PowerPoint 97–2003 (`.doc`, `.xls`, `.ppt`), Pages, Numbers, Keynote and others | [LibreOffice](https://www.libreoffice.org) |
| Images and scanned PDFs | [Tesseract](https://tesseract-ocr.github.io) (with the Turkish language data), plus Poppler's `pdftoppm` for PDFs |
| PDFs the built-in reader cannot parse | Poppler's `pdftotext` |
`llmgw-kb formats` says which helpers it found. Text in UTF-8, UTF-16, or
the Windows Turkish and Western code pages is read correctly.
A file that cannot be read is skipped with the reason, and the run goes on.
Reasons include a locked Office file, a format with no helper, and a
scanned page with no OCR.
### What leaves your machine
For each new or changed document, one request leaves the machine, over the
gateway's TLS connection: its name, its file's SHA-256 digest and size, and
its passages' text. The gateway embeds the passages through `/v1/embeddings`
as you, so your key's limits, budget and guardrails apply as if you had
asked. That is about a kilobyte a passage; embedding here (`--embed-here`)
brings each vector down and sends it up again, some thirty times more.
The file itself is never sent. `--dry-run` shows exactly what would be.
### The ledger
`llmgw-kb` keeps a small SQLite database of what it did with every
document: the file it came from, its size, time and digest, and whether it
was sent, skipped or failed, with the reason.
| System | Where it is |
|---|---|
| Windows | `%AppData%\llmgw-kb\ledger.db` |
| macOS | `~/Library/Application Support/llmgw-kb/ledger.db` |
| Linux | `~/.config/llmgw-kb/ledger.db` |
Deleting the ledger is safe: the next run hashes every file once, finds what
the collection already has, and sends nothing that is unchanged. `--ledger`
uses another file.
## Asking questions of a collection
A chat request names the collections to answer from in a header. Any client
that can add a header can do it: the OpenAI and Anthropic SDKs, Continue, or
curl.
```sh
curl https://llm.example.com/v1/chat/completions \
-H "Authorization: Bearer $LLMGW_API_KEY" \
-H "X-Llmgw-Knowledge: politikalar" \
-d '{"model": "qwen2.5-coder-14b-instruct",
"messages": [{"role": "user", "content": "Kaç gün yıllık iznim var?"}]}'
```
```python
client.chat.completions.create(
model="qwen2.5-coder-14b-instruct",
messages=[{"role": "user", "content": "Kaç gün yıllık iznim var?"}],
extra_headers={"X-Llmgw-Knowledge": "politikalar"},
)
```
What the gateway does with such a request:
1. **It finds the collections.** It looks up the ones named, by name or ID,
among those the key may read. A collection it may not read is answered
404, the same as one that does not exist.
2. **It embeds the question.** The last user message is embedded by the
collection's model, on the caller's own account: their limits, budget and
guardrails apply.
3. **It searches.** The collections are searched by meaning and by words
(below).
4. **It adds the passages.** The best passages are numbered and added to the
system message, with an instruction to answer from them, cite them as
[1], [2], and say so when they do not hold the answer. The system message
is created if the request has none. Passages that would not fit the
model's context are left out, the weakest first.
5. **It judges and sends the request.** The request, passages included, then
goes through the guardrails and to the model as usual. A document
carrying instructions aimed at its reader is judged like anything else
the model reads.
On the portal's chat page, **Documents** picks a collection the person may
read in the chosen project, the default, or none. Under each answer, the
sources it was given are listed by number.
How it is shaped:
| | |
|---|---|
| `X-Llmgw-Knowledge` | Collections, comma separated; `none` for no passages, whatever the default. |
| `X-Llmgw-Knowledge-K` | How many passages, 1 to 20; 5 by default. |
| `X-Llmgw-Project` | For a personal key: the project it acts in. |
| `x-llmgw-sources` (answer) | The passages given, as `document#chunk`, in their numbered order; empty when nothing matched. |
Behaviour to know about:
- **No passages matched.** The model is told that nothing in the documents
answers the question, and asked to say so rather than answer from general
knowledge.
- **Other APIs.** Requests through `/v1/responses` and `/v1/messages`
(Codex, Claude Code) work the same way, since they are answered as chat
completions.
- **Separate prefix caches.** On vLLM replicas, a request given passages
carries a `cache_salt` derived from the collections they came from.
Requests that could be given the same passages still share cached blocks,
and no one else's do: not another organization's, nor another team's of
the same one. How fast an answer starts therefore says nothing about what
someone else asked.
- **No answer cache.** Answers from knowledge bases are never served from
the answer cache, because two keys of one project can read different
collections.
### How a passage is chosen
Each passage scores the following, and the highest scores win:
- its similarity in meaning to the question (cosine, 0 to 1), plus
- up to 0.3 for the share of the question's words it contains.
Words almost every passage has ("ne", "için", "the", "of") are not counted.
The meaning does most of the work: "how much leave do fathers get" finds the
paternity page, not the annual-leave page that shares more of its words.
The word match helps with what meaning cannot carry, such as a form number,
a product code or a name.
Metrics: `llmgw_kb_retrievals_total{result}` (found, nothing, refused) and
`llmgw_kb_retrieval_seconds`.
## Training an adapter on a collection
An adapter (LoRA) changes how a model *writes*: the organisation's words,
format and tone. It does not reliably teach facts. On the PoC, a small
model trained on nine policy documents answered 4 of 9 rephrased questions
right and mixed facts up on the rest, while the same questions through the
knowledge base got 9 of 9. Use both: the knowledge base for what answers
say, an adapter for how they sound.
```sh
# 1. Questions and answers from the collection, written by a model of the
# cluster. Read the file through and correct it.
llmgw ctl kb dataset Acme/policies --model qwen2.5-coder-14b-instruct --out policies.jsonl
# 2. Train on a node. --gpu-memory is what the job needs of a card; it
# runs beside an engine when the engine leaves that much free.
llmgw ctl train start policies-tone --model qwen2.5-0.5b-instruct \
--base-repo Qwen/Qwen2.5-0.5B-Instruct --node cuda01 --gpu-memory 2300 --data policies.jsonl
llmgw ctl train show JOB --follow
# 3. Make it the model's adapter, kept on the mirror.
llmgw ctl train publish JOB --restart
```
The node trains with its installed vLLM's torch and a small overlay (peft
and accelerate), as the engines' user. For a model llama.cpp serves, it also
converts the adapter to GGUF with the installed llama.cpp's own code. What
it makes is kept by the control plane (`llmgw ctl train download`).
An adapter is private like its collection:
- **It has an owner.** An adapter trained on a collection carries some of
what the collection says in its weights, so it belongs to a part of the
tree.
- By default the owner is the collection's owner.
- `--owner` names another, which must be able to read every collection
the pairs came from.
- Only callers in the owner's part of the tree see the adapter. To anyone
else it is a model that does not exist, absent from `/v1/models` too.
- **It is public only by choice.** `--public` makes an adapter every
caller's. It is refused for pairs from a knowledge base, so it is only for
pairs written by hand.
## How it is kept apart
Passages live in PostgreSQL, partitioned by organization, with an index per
collection. Every search and every write names the organization and the
collections the caller may use. On top of that, row-level security on the
table checks the same thing again, so a query that forgot its filter
returns nothing rather than another tenant's passages.
+95
View File
@@ -0,0 +1,95 @@
# llmgw and Kong
For a site that already runs Kong. The two meet over plain HTTP: Kong in
front of llmgw, as one more API it governs, or Kong behind llmgw, as an
OpenAI-compatible provider. Nothing of either is built into the other.
Tried with Kong Gateway 3.9 (`kong:3.9`, the last open-source release; 3.10
and later need a Kong licence) in DB-less mode, in front of llmgw.
## Kong in front of llmgw
Two ways, both tried.
**A plain route.** Kong forwards the request as it came. Tried with chat
completions, streamed and not; llmgw's other APIs — Anthropic's
`/v1/messages`, `/v1/responses`, embeddings, pictures — go the same way,
since Kong does not read them. Kong adds its own authentication, rate limits
and logging; llmgw still checks the key, the budget and the guardrails.
```yaml
_format_version: "3.0"
services:
- name: llmgw
url: https://llm.example.com # llmgw's client port
routes:
- name: llmgw
paths: [/llmgw] # /llmgw/v1/chat/completions → /v1/chat/completions
```
**Kong's `ai-proxy` plugin**, for Kong's own token statistics on a chat
route. llmgw is an OpenAI provider whose `upstream_url` is llmgw:
```yaml
services:
- name: llmgw-chat
url: https://llm.example.com
routes:
- name: chat
paths: [/chat]
plugins:
- name: ai-proxy
config:
route_type: llm/v1/chat
logging: {log_statistics: true, log_payloads: false}
model:
provider: openai
name: qwen-coder # a model llmgw serves
options:
upstream_url: https://llm.example.com/v1/chat/completions
```
**Keys.** Without an `auth` block, `ai-proxy` passes the client's
`Authorization` header on: each team keeps its own llmgw key, and llmgw's
budgets, limits, guardrails and usage stay per team. With
`auth: {header_name: Authorization, header_value: "Bearer sk-llmgw-…"}` Kong
sends one key of its own instead, and llmgw sees every caller as that key —
then budgets per team are Kong's to keep.
**What Kong counts.** For a whole answer Kong reads llmgw's `usage` and
records prompt and completion tokens as llmgw did. For a streamed answer
Kong 3.9 records an estimate of the prompt tokens and no completion tokens,
even with `stream_options.include_usage` set — the usage chunk reaches it
and is not read. llmgw's own accounting counts both; use its usage export
(`llmgw ctl usage export`) or `/admin/usage` for figures to bill by.
**Timeouts.** A long answer from a model still loading outlasts Kong's
60-second defaults: set the service's `read_timeout` to the time a model may
take to start, as for nginx in [install.md](install.md).
## Kong behind llmgw
Not tried here. Kong's AI routes speak OpenAI's API, so llmgw reaches models Kong fronts as
it reaches any provider: a model whose source is `external`.
```yaml
- name: kong-gpt
source: external
external:
url: https://kong.example.com/openai # Kong's route, without /v1
model: gpt-4o-mini
api_key_file: /etc/llmgw/secrets/kong-consumer.key
```
The key is the one Kong's own authentication asks of a consumer. As a
fallback (`fallbacks: [kong-gpt]`) it is used only when the cluster has no
room.
## Which to choose
Kong in front when the site's API policy says every API goes through Kong:
llmgw is then one more upstream, and its per-team accounting still holds
when each team keeps its own key. Kong behind when the site already reaches
outside providers through Kong and llmgw should too. llmgw does not need
Kong for anything it does itself — keys, budgets, guardrails, routing to
its own replicas.
+145
View File
@@ -0,0 +1,145 @@
# Large models: several cards, several nodes
A model too big for one card runs on several. llmgw chooses how from the
model's own figures and the cluster as it is; this page is the rules it
follows, the commands, and what to look at when a first one is brought up
on new hardware.
## How a model is split
In order, the first that fits:
1. **One card.** Nothing to split.
2. **Several cards of one node, tensor parallel.** Every layer is cut
across the cards, so they talk for every token: they must be on one
NVLink domain. The agent reads `nvidia-smi topo -m` and gives a replica
the cards with the most NVLink between them
([install.md](install.md#nvlink-if-your-gpus-have-it)).
3. **Several nodes, pipeline parallel between them.** Each node holds a
run of layers on all its cards (tensor parallel inside it), and only a
layer's output crosses to the next node. The nodes must share one
InfiniBand or RoCE fabric: a replica never spans two, and never a node
with none. Any number of nodes, not only two or four.
The plan takes the fewest cards, then the fewest nodes, then the better
precision up to eight bits.
**Tensor width and the KV heads.** The cards split the model's KV heads
among them, or past their number each card holds a copy of one. So the
width divides the KV heads or is a multiple of them: a model with 8 KV
heads runs 2, 4, 8 or 16 wide, not 12. Qwen3-235B has 4 and runs on 8.
**Mixture of experts.** A model of experts (Qwen3-235B-A22B, DeepSeek-V3,
Llama 4, Mixtral) on more than one card is served with vLLM's
`--enable-expert-parallel`: each card holds whole experts rather than a
slice of every one, which is less traffic per token. It is read from
`config.json` (`num_experts`, `n_routed_experts`, `num_local_experts`).
**When it does not fit.** A smaller precision first: an FP8 or AWQ
repository of the same model, or a GGUF quantisation on llama.cpp if one
node's cards hold it. Then more nodes. `llmgw ctl model plan REPO
--precision bf16,fp8` shows each precision's fit from the model's config.
## What a way needs
Each way the plan offers says what a replica needs of each card, part by
part, and of all its cards together:
```
$ llmgw ctl model plan meta-llama/Llama-3.3-70B-Instruct --format safetensors --context 32768
FILE SIZE BEST WAY A CARD OF ALL CARDS NOW
original 131 GB 4 × h100-80g 37.4 GB 72 GB 150 GB free
Best: original on 4 × h100-80g: 37.4 GB a card (weights 32.9 + context 2.5 + engine 2.0) of 72 usable; 150 GB on 4 cards.
The context costs 0.31 GB per 1000 tokens, the whole model (--context to plan another).
```
- **weights:** the files, divided over the cards.
- **context:** the KV cache for the context planned, divided likewise.
- **engine:** the engine's own: 2 GB and a fiftieth of the weights for vLLM.
- **usable:** 90% of a card; the rest is the driver's and fragmentation's.
The console's **Serve a model** shows the same for every way a file runs.
A context longer than planned is checked against it before anything is
fetched.
## Serving one
Without flags the plan's best way is taken:
```
llmgw ctl model serve Qwen/Qwen3-235B-A22B-Instruct-2507-FP8 --format safetensors --context-length 32768
```
Or a way given by hand, for instance two nodes of eight:
```
llmgw ctl model serve meta-llama/Llama-3.3-70B-Instruct --format safetensors \
--gpu-class h100-80g --gpus 8 --nodes 2 --env NCCL_DEBUG=INFO
```
`--gpus` is the cards on each node and `--nodes` the nodes. The pipeline
is the nodes and the tensor width the cards of each.
`--env KEY=VALUE` goes into the engine's environment on every node:
`NCCL_DEBUG=INFO` while bringing a cluster up, `NCCL_IB_*` to steer it.
Only the variables engines are tuned with are taken. llmgw already sets
these:
- `NCCL_IB_HCA` to the fabric's adapters;
- `NCCL_IB_GID_INDEX` on RoCE;
- `NCCL_SOCKET_IFNAME` to the management network;
- for an NVLink domain spanning nodes, `NCCL_MNNVL_ENABLE`.
The job's first step says what it needs of each card. The weights then
come to the mirror once and from it to each node. The startup timeout (20
minutes unless the profile's `startup_timeout` says otherwise) runs from
when a node's download last moved. So a model of hundreds of gigabytes is
not failed while it is still arriving, and one whose download stops is
failed that long after.
## Watching it come up
| What | Where |
|---|---|
| The replica and its shards, one per node, each with its download | `llmgw ctl replicas` |
| Why it is not placed | `llmgw ctl explain-placement MODEL` |
| A node's engine output (NCCL's lines with `NCCL_DEBUG=INFO`) | `llmgw ctl logs NODE --source SHARD` |
| The bandwidth between two nodes, rail by rail | `llmgw ctl fabric-test NODE NODE` |
| Each node's GPUs, NVLink and adapters | `llmgw doctor` on the node; its page under /admin |
| Every step of a start, across nodes | the `replica start` trace ([install.md](install.md)) |
With `NCCL_DEBUG=INFO`, NCCL says which transport it took between nodes:
`NET/IB` is the fabric. `NET/Socket` means it fell back to TCP, and the
model will run, slowly.
## Bringing it up on new hardware
A first run on a fabric, in order. Each step is worth its output if
something goes wrong.
1. **The nodes.** Add each with its fabric:
`llmgw node add host --fabric ib-a:mlx5_0,mlx5_1`. Then
`llmgw ctl nodes` shows every node ready, on `ib-a`.
2. **The fabric.** `llmgw ctl fabric-test node1 node2`: each rail at its
rated speed (a 400 Gb/s rail near 45 GB/s).
3. **One node, several cards.** A dense 70B at bf16 on four or eight
cards of one node, by the plan:
`llmgw ctl model serve meta-llama/Llama-3.3-70B-Instruct --format safetensors`.
Ready, then a question answered.
4. **Two nodes.** The same model forced over two:
`--gpu-class CLASS --gpus 8 --nodes 2 --env NCCL_DEBUG=INFO`, under
another `--name`. Ready, answering, and `NET/IB` in both shards' logs.
5. **Experts.** A mixture-of-experts model on one node's eight cards,
for instance `Qwen/Qwen3-235B-A22B-Instruct-2507-FP8`: its profile
carries `--enable-expert-parallel` (`llmgw ctl models`, or its
definition from the admin API).
6. **A node lost.** With the two-node replica serving, stop one node's
agent (`systemctl stop llmgw-agent`): the replica fails and is placed
again once the node is back or another has room. Nothing is left
holding cards.
For each, worth keeping:
- the time from serve to ready;
- tokens a second from `llmgw ctl bench`;
- the plan's per-card figure against what `nvidia-smi` shows once loaded;
- anything it said that was wrong.
Binary file not shown.
+72 -19
View File
@@ -54,10 +54,20 @@ 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:
The account page's **Connect your editor** makes a key for VS Code, Continue,
Codex or Cline and shows what the tool needs with that key, this gateway's
address and a model already in it:
- **VS Code** — llmgw's own extension, downloaded from the gateway at
`/setup/vscode/llmgw.vsix` with this gateway's address already in it. It
offers every chat model the key may use to VS Code itself: the chat's model
picker, agent mode, and any extension that asks VS Code for a language model
(Cline's "VS Code LM API" provider among them), with no setup of their own.
**llmgw: Sign In** in VS Code opens this page with VS Code chosen; the page's
**Open in VS Code** link hands the key back, and the extension keeps it in
VS Code's secret storage. A link the extension did not ask for is confirmed
first, with the gateway's address shown, since it decides where code is sent.
**llmgw: Enter API Key** takes a pasted one instead.
- **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
@@ -182,22 +192,41 @@ auth:
A personal key has no project, so it keeps no conversations or agent notes;
those are kept per project.
### Organization managers
### Organization managers and admins
A member of an organization can be made its manager:
A member of an organization can be made its manager, who sees it, or its
admin, who also changes it:
```
llmgw users join boss@acme.com --org Acme --role manager
llmgw users join lead@acme.com --org Acme --role admin
```
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,
or with the role chosen on their organization membership on `/admin/users`.
Both get 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. An administrator following `/org` lands on the same organization's panel
on the people page instead, where all of that can be changed.
people do elsewhere is not.
A manager's page is read-only. An organization's admin also changes the
organization from it:
- adds and renames its teams and projects, and removes an empty one;
- adds a person by their sign-in address to the organization, a team or a
project, and takes them out (their keys there are revoked with it);
- makes a member of the organization a manager or an admin, or neither;
- revokes a key spent in its projects.
What reaches past the organization stays with the cluster's administrators:
its limits and budgets, approving or suspending an account (someone not yet
approved cannot be added), the organization's own name, and anything else on
the cluster. An admin cannot take away their own role. A group from single
sign-on or Active Directory that puts someone in the organization does not, by
saying so again at the next sign-in, take a manager's or admin's role away.
An administrator of the cluster following `/org` lands on the same
organization's panel on the people page instead, where all of that can be
changed.
`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;
@@ -379,16 +408,38 @@ is what someone gets from a borrowed laptop, and the password is what they do no
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:
Someone who has forgotten theirs follows **Forgotten your password?** on the
sign-in page. With mail set up in the cluster's settings, a link to set a new one
is mailed to their address — the page says the same whether or not the address
has an account, so nobody can find out who does from it:
```
llmgw ctl config set mail '{smtp: smtp.example.com:587, from: llmgw@example.com,
username: llmgw, password_file: /etc/llmgw/secrets/smtp, base_url: https://llm.example.com}'
```
`base_url` is where the link points, and it is never taken from the request:
otherwise a stranger could ask for someone's link from a page of their own. A
link works once, for an hour, and a newer one replaces it.
Without mail, the page says to ask an operator, who makes the link and hands it
over however suits:
```
llmgw users reset-link ayse@example.com --store … # --base-url without mail.base_url
```
Or sets a temporary password to give them, and has them change it from their
profile:
```
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.
Either way 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. People who sign in through single sign-on have no password
here, and get no link.
## Trying a model in the browser
@@ -485,8 +536,10 @@ included: `llmgw ctl audit`, or **Audit** in the console.
suspends accounts from it; see [install.md](install.md#single-sign-on).
Without them accounts are local, with local passwords.
- **Staff roles are coarse.** `viewer`, `operator`, `billing` and `admin`
(`llmgw users role`) decide what someone may see and do under /admin; an
organization's manager can view it but not change it. Every change is in
the audit log (`llmgw ctl audit`).
(`llmgw users role`) decide what someone may see and do under /admin, and a
page draws only the forms that person may send: a viewer sees no forms. An
organization's manager can view it; its admin also changes its teams,
projects, people and keys. Every change is in the audit log (`llmgw ctl
audit`).
- **Rate limits are per gateway instance.** With two instances each counts its own,
so the effective limit doubles. v1 is single-instance by design.
+31 -3
View File
@@ -8,7 +8,7 @@ explains every choice.
| | |
|---|---|
| **A control plane** | One machine, or three for high availability (the store replicated, either one able to take over). Linux, x86-64 or ARM64: Debian 12, Ubuntu 22.04+ or RHEL 10. No GPU needed; 2 GB RAM, 20 GB disk each — 8 GB RAM when it is also a node running the starter model and the two judges. |
| **A control plane** | One machine, or three for high availability (the store replicated, either one able to take over). Linux, x86-64 or ARM64: Debian 12, Ubuntu 22.04+ or RHEL 10. No GPU needed; 2 GB RAM, 20 GB disk each — 12 GB RAM when it is also a node running the starter model and the two judges, which setup checks before it installs anything; four cores or more, or the judges on machines of their own, keep a judgment within its wait. |
| **GPU nodes** (optional to start) | The same Linux, with the NVIDIA driver installed. vLLM needs driver 525 or newer and a Turing GPU or newer; a V100 gets an older vLLM. |
| **root** | on the control plane. On the other two of a three-machine setup, `sudo` (its password is asked for if it needs one); on nodes, `sudo` without a password. |
| **SSH** | from the control plane to the other machines. The setup screens take a key, the ssh agent or a password; `node add` uses ssh keys. A machine nothing may log into joins with a code instead (`llmgw join`). |
@@ -52,7 +52,7 @@ install:
- your administrator account. **The password is shown 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;
- guardrails on every request, with a judge model (Llama Guard 3 1B, two
- guardrails on every request, with a judge model (Qwen3Guard-Gen 0.6B, two
replicas) on CPUs — a request it cannot check is refused, not let through — and API keys that expire within 90 days. What is hardened, and how
to loosen it: [install.md](install.md#2-the-control-plane).
@@ -146,7 +146,35 @@ 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.
Hugging Face, showing only what your nodes can run: judged by the cluster as it
is now — the largest card and its compute capability, the most memory on one
node — so a model too big for them, in a form those cards lack (FP8 needs 8.9),
or in neither GGUF nor safetensors is left out, and the page says how many.
Without GPUs it lists GGUF only. Untick **only what these nodes can run** to
see everything. On a model's page a file too big for the cluster is shown
without a **Fetch** button, with what it would need.
The **CPU tok/s** column is about how fast one reply would come on the CPU of
the fastest node whose memory holds the model at Q4_K_M; a model's page gives
it for each GGUF file by its own size. Every node's agent measures how fast its
memory reads when it starts (it is in the agent's log as `memory_gbs`), and a
token on a CPU reads every weight once: the estimate is that speed over the
model's size — for a model of experts named like `30B-A3B`, its active part.
On the test cluster it came within 15% of what llama.cpp measured. Several
replies at once share the reads and each is slower.
**GPU tok/s** is the same on the cards, for the layout this cluster would
give the model — the fewest cards that hold it, of each class, the fastest of
those; its tooltip says which ("2× h100-80g, NVLink, vLLM", "2 nodes × 1×
a100-80g over ib0 (InfiniBand 200 Gb/s), vLLM"). A card's memory bandwidth is
known from its model name (an H100 SXM and an H100 PCIe differ by half); a card
not in the table gets no figure. Tensor parallel across a node's cards divides
what each reads, and adds two all-reduces a layer, short over NVLink and
longer over PCIe; llama.cpp across cards splits by layer, so the cards take
turns and a reply is no faster than on one; a pipeline across nodes takes turns
too, and adds a hop over the fabric for each node. One card's figures were
measured on the test cluster's RTX 6000; tensor parallel and the fabric are
from what NCCL is known to take, not measured here.
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
+1243 -6
View File
File diff suppressed because it is too large. Load diff
+6 -1
View File
@@ -12,12 +12,14 @@ require (
github.com/charmbracelet/x/ansi v0.11.6
github.com/coreos/go-oidc/v3 v3.21.0
github.com/go-jose/go-jose/v4 v4.1.4
github.com/go-ldap/ldap/v3 v3.4.15
github.com/go-zeromq/zmq4 v0.17.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/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0
github.com/mattn/go-isatty v0.0.24
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.3
@@ -34,6 +36,8 @@ require (
golang.org/x/net v0.59.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/text v0.42.0
google.golang.org/protobuf v1.36.12
modernc.org/sqlite v1.59.0
@@ -42,6 +46,7 @@ require (
require (
filippo.io/edwards25519 v1.2.0 // indirect
filippo.io/hpke v0.4.0 // indirect
github.com/Azure/go-ntlmssp v0.1.1 // indirect
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
@@ -54,6 +59,7 @@ require (
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-asn1-ber/asn1-ber v1.5.8 // 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
@@ -86,7 +92,6 @@ require (
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
golang.org/x/sys v0.48.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
+24
View File
@@ -8,8 +8,12 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
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/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
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=
@@ -46,8 +50,12 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
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-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
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=
@@ -69,6 +77,8 @@ 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/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
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=
@@ -81,6 +91,18 @@ 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/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
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=
@@ -95,6 +117,8 @@ 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/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
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=
+37
View File
@@ -448,3 +448,40 @@ func TestAHashThatCannotGetATurnSaysTheServerIsBusy(t *testing.T) {
t.Errorf("a hash with no turn: %v", err)
}
}
func TestAForgottenPasswordIsResetOnceThroughALink(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")
// Waiting for approval: no link, said the same as for nobody.
if _, _, err := svc.RequestReset(ctx, st, "ayse@example.com"); !errors.Is(err, ErrNoReset) {
t.Fatalf("a pending account got a link: %v", err)
}
if _, _, err := svc.RequestReset(ctx, st, "nobody@example.com"); !errors.Is(err, ErrNoReset) {
t.Fatalf("an unknown address: %v", err)
}
must(t, st.ApproveUser(ctx, u.ID, "proj", "ilker", time.Now()))
session, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword)
must(t, err)
token, got, err := svc.RequestReset(ctx, st, "AYSE@example.com")
if err != nil || token == "" || got.ID != u.ID {
t.Fatalf("an active account: %q %+v %v", token, got, err)
}
if err := svc.ResetPassword(ctx, st, st, token, "short"); !errors.Is(err, ErrWeakPassword) {
t.Fatalf("a weak password: %v", err)
}
next := goodPassword + "-new"
must(t, svc.ResetPassword(ctx, st, st, token, next))
if _, _, err := svc.Resolve(ctx, session); err == nil {
t.Error("a session from before the reset still works")
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", next); err != nil {
t.Errorf("the new password does not sign in: %v", err)
}
if err := svc.ResetPassword(ctx, st, st, token, next+"2"); !errors.Is(err, ErrNoReset) {
t.Errorf("a link used twice: %v", err)
}
}
+63
View File
@@ -0,0 +1,63 @@
package account
import (
"context"
"errors"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
type fakeDirectory struct {
asked []string
u store.User
err error
}
func (f *fakeDirectory) SignIn(_ context.Context, login, _ string) (store.User, error) {
f.asked = append(f.asked, login)
return f.u, f.err
}
// With a directory, a name the store has no password for is the
// directory's to check; an account with a password here — the
// administrator init made — is still checked here, so a directory that is
// down locks nobody out.
func TestPasswordsKeptElsewhereAreTheDirectorysToCheck(t *testing.T) {
svc, st := newService(t)
ctx := t.Context()
hash, _ := HashPassword("local-secret")
admin := store.User{ID: "usr-admin", Email: "admin@example.com", Name: "a", PasswordHash: hash,
State: store.UserActive, CreatedAt: time.Now()}
ada := store.User{ID: "usr-ada", Email: "ada@corp.example", Name: "Ada", PasswordHash: "!ldap",
State: store.UserActive, CreatedAt: time.Now()}
for _, u := range []store.User{admin, ada} {
if err := st.CreateUser(ctx, u); err != nil {
t.Fatal(err)
}
}
dir := &fakeDirectory{u: ada}
svc.SetDirectory(dir)
if _, _, err := svc.SignIn(ctx, "admin@example.com", "local-secret"); err != nil || len(dir.asked) != 0 {
t.Fatalf("the local administrator: %v, directory asked %v", err, dir.asked)
}
// A name, not an address, and an account kept by the directory.
for _, login := range []string{`CORP\ada`, "ada@corp.example"} {
if tok, _, err := svc.SignIn(ctx, login, "ad-secret"); err != nil || tok == "" {
t.Errorf("%s: %v", login, err)
}
}
if len(dir.asked) != 2 || dir.asked[0] != `CORP\ada` {
t.Errorf("the directory was asked %v", dir.asked)
}
dir.err = ErrBadCredentials
if _, _, err := svc.SignIn(ctx, "ada", "wrong"); !errors.Is(err, ErrBadCredentials) {
t.Errorf("a password the directory refused: %v", err)
}
// A reset link would give a directory's account a password here.
if _, _, err := svc.RequestReset(ctx, st, "ada@corp.example"); !errors.Is(err, ErrNoReset) {
t.Errorf("a directory's account was sent a reset link: %v", err)
}
}
+95
View File
@@ -80,8 +80,26 @@ type Service struct {
mu sync.Mutex
attempts map[string]*attemptWindow
// dir checks the passwords of accounts this store keeps none for.
dir Directory
}
// Directory checks a password against an outside directory — Active
// Directory over LDAP — and answers the account to sign in: made or kept in
// step with the directory. Its errors are this package's ErrBadCredentials
// and ErrSuspended for the person's own, and anything else for the
// directory's.
type Directory interface {
SignIn(ctx context.Context, login, password string) (store.User, error)
}
// SetDirectory has passwords for accounts with none here, and names that are
// not addresses, checked by the directory. An account with a password here
// — the administrator init made — is still checked here, so a directory that
// is down locks nobody out.
func (s *Service) SetDirectory(d Directory) { s.dir = d }
type attemptWindow struct {
count int
since time.Time
@@ -137,6 +155,7 @@ func (s *Service) SignUp(ctx context.Context, email, name, password string) erro
// 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) {
login := strings.TrimSpace(email)
email = store.NormalizeEmail(email)
// Counted per address and the client trying it, so somebody guessing
// from elsewhere cannot lock the owner out of their own account; and per
@@ -151,6 +170,15 @@ func (s *Service) SignIn(ctx context.Context, email, password string) (token, cs
}
u, err := s.store.UserByEmail(ctx, email)
// No password kept here — the directory's, or nobody's — and a
// directory to ask: it decides.
if s.dir != nil && (errors.Is(err, store.ErrNotFound) || (err == nil && strings.HasPrefix(u.PasswordHash, "!"))) {
if u, err = s.dir.SignIn(ctx, login, password); err != nil {
return "", "", err
}
s.clearAttempts(byClient)
return s.open(ctx, u)
}
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.
@@ -180,7 +208,11 @@ func (s *Service) SignIn(ctx context.Context, email, password string) (token, cs
case store.UserSuspended:
return "", "", ErrSuspended
}
return s.open(ctx, u)
}
// open makes a session for a person whose password was right.
func (s *Service) open(ctx context.Context, u store.User) (token, csrf string, err error) {
if token, err = store.NewSessionToken(); err != nil {
return "", "", err
}
@@ -373,3 +405,66 @@ func (s *Service) OpenSession(ctx context.Context, userID string) (token, csrf s
}
return token, csrf, nil
}
// ResetLinkTTL is how long a password reset link works.
const ResetLinkTTL = time.Hour
// ResetStore keeps password reset links.
type ResetStore interface {
UserByEmail(ctx context.Context, email string) (store.User, error)
CreatePasswordReset(ctx context.Context, userID string, ttl time.Duration) (string, error)
PasswordResetUser(ctx context.Context, token string) (string, error)
UsePasswordReset(ctx context.Context, token string) (string, error)
}
// ErrNoReset is an address that gets no reset link: none has an account, the
// account signs in through single sign-on, or it is not active. Never told to
// whoever asked, who learns the same either way.
var ErrNoReset = errors.New("no reset link for that address")
// RequestReset mints a reset link token for the person an address belongs
// to, when they sign in with a password and are active. A few a quarter of
// an hour per address: each sends a mail.
func (s *Service) RequestReset(ctx context.Context, rs ResetStore, email string) (string, store.User, error) {
email = store.NormalizeEmail(email)
if !validEmail(email) {
return "", store.User{}, ErrNoReset
}
if !s.allowAttempt("reset:"+email, 3) {
return "", store.User{}, ErrTooManyTries
}
u, err := rs.UserByEmail(ctx, email)
// An account whose password is kept elsewhere — "!oidc", "!scim",
// "!ldap" — gets no link: setting one here would give it a second way
// in that its directory knows nothing of, and outlives its leaving.
if err != nil || u.PasswordHash == "" || strings.HasPrefix(u.PasswordHash, "!") || u.State != store.UserActive {
return "", store.User{}, ErrNoReset
}
token, err := rs.CreatePasswordReset(ctx, u.ID, ResetLinkTTL)
if err != nil {
return "", store.User{}, err
}
s.log.Info("a password reset link was made", "user", u.ID)
return token, u, nil
}
// ResetPassword sets a password from a reset link, which is spent, and signs
// the person out everywhere: if they lost it, someone else may have it.
func (s *Service) ResetPassword(ctx context.Context, rs ResetStore, pw PasswordChanger, token, next string) error {
hash, err := HashPassword(next)
if err != nil {
return err
}
userID, err := rs.UsePasswordReset(ctx, token)
if err != nil {
return ErrNoReset
}
if err := pw.SetPassword(ctx, userID, hash); err != nil {
return err
}
if err := pw.DeleteUserSessions(ctx, userID); err != nil {
return err
}
s.log.Info("a password was set from a reset link", "user", userID)
return nil
}
+34 -6
View File
@@ -78,10 +78,41 @@ func (a *Accountant) RestoreBudgets(ctx context.Context, s store.Store, period s
if start.IsZero() {
return nil
}
rows, err := s.SpendSinceByScope(ctx, start)
spend, keys, err := storedSpend(ctx, s, start)
if err != nil {
return fmt.Errorf("restoring budgets: %w", err)
}
a.budgets.Restore(period, spend)
a.log.Info("restored budgets from the store",
"keys", keys, "counters", len(spend), "since", start)
return nil
}
// CatchUpBudgets raises each counter to what the store has recorded, where
// that is more. Run every so often by a router that is one of several taking
// requests: the others' spend reaches it only through the store. Never
// lowers a counter, which leads the store by whatever this process has not
// written yet.
func (a *Accountant) CatchUpBudgets(ctx context.Context, s store.Store, period store.Period) error {
start := PeriodStart(period, time.Now())
if start.IsZero() {
return nil
}
spend, _, err := storedSpend(ctx, s, start)
if err != nil {
return fmt.Errorf("reading budgets: %w", err)
}
a.budgets.Raise(period, spend)
return nil
}
// storedSpend is the spend since start the store has recorded, by counter,
// and how many keys it came from.
func storedSpend(ctx context.Context, s store.Store, start time.Time) (map[string]int64, int, error) {
rows, err := s.SpendSinceByScope(ctx, start)
if err != nil {
return nil, 0, 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
@@ -113,10 +144,7 @@ func (a *Accountant) RestoreBudgets(ctx context.Context, s store.Store, period s
}
}
}
a.budgets.Restore(period, spend)
a.log.Info("restored budgets from the store",
"keys", len(rows), "counters", len(spend), "since", start)
return nil
return spend, len(rows), nil
}
// Reservation is what Check returns: the estimate it charged, so Record can
@@ -323,7 +351,7 @@ func (a *Accountant) Record(res Reservation, u store.Usage) {
if u.ServedModel != "" && u.ServedModel != u.Model && a.pricing != nil {
price = a.pricing.For(u.ServedModel)
}
u.CostMicros = price.Cost(u.PromptTokens, u.CompletionTokens)
u.CostMicros = price.Cost(u.PromptTokens, u.CachedTokens, u.CompletionTokens, u.Images, u.Meshes)
for _, b := range res.Budgets {
a.budgets.Add(b.Counter, b.Period, u.CostMicros)
}
+46 -3
View File
@@ -39,17 +39,44 @@ 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 {
if got := p.Cost(1, 0, 0, 0, 0); got != 1 {
t.Errorf("one prompt token cost %d", got)
}
if got := p.Cost(1000, 1000); got != 4000 {
if got := p.Cost(1000, 0, 1000, 0, 0); got != 4000 {
t.Errorf("a thousand of each cost %d, want 4000", got)
}
if got := p.Cost(0, 0); got != 0 {
if got := p.Cost(0, 0, 0, 0, 0); got != 0 {
t.Errorf("an empty request cost %d", got)
}
}
func TestACachedPromptCostsTheCachedRate(t *testing.T) {
p := Price{PromptPer1k: 1000, CachedPer1k: 100, CompletionPer1k: 3000}
// 10,000 prompt tokens of which 9,000 were read from the cache: 1,000 at
// the prompt rate and 9,000 at the cached one.
if got, want := p.Cost(10000, 9000, 0, 0, 0), int64(1000+900); got != want {
t.Errorf("a 90%% cached prompt cost %d, want %d", got, want)
}
// More cached than prompt is an engine's miscount, not a refund.
if got := p.Cost(100, 500, 0, 0, 0); got != 10 {
t.Errorf("cached beyond the prompt cost %d, want 10", got)
}
// No cached rate: cached tokens cost what any prompt token does.
if got := (Price{PromptPer1k: 1000}).Cost(10000, 9000, 0, 0, 0); got != 10000 {
t.Errorf("without a cached rate a cached prompt cost %d, want 10000", got)
}
}
func TestAPictureCostsItsPriceOnTopOfItsPrompt(t *testing.T) {
p := Price{PromptPer1k: 1000, PerImage: 50000}
if got := p.Cost(20, 0, 0, 3, 0); got != 20+150000 {
t.Errorf("three pictures from a 20-token prompt cost %d", got)
}
if (Price{PerImage: 1}).Zero() {
t.Error("a model priced per picture reads as free, and would pass a spent budget")
}
}
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.
@@ -189,6 +216,22 @@ func TestBudgetSurvivesARestart(t *testing.T) {
}
}
func TestCatchingUpRaisesButNeverLowers(t *testing.T) {
// Another router's spend arrives through the store; this one's own, not
// written yet, must not be wiped by a store that is behind.
b := NewBudgets()
b.Add("mine", store.PeriodMonth, 900)
b.Add("shared", store.PeriodMonth, 100)
b.Raise(store.PeriodMonth, map[string]int64{"mine": 400, "shared": 700, "theirs": 50})
for key, want := range map[string]int64{"mine": 900, "shared": 700, "theirs": 50} {
if got := b.Spent(key, store.PeriodMonth); got != want {
t.Errorf("%s: spent %d, want %d", key, got, want)
}
}
}
func TestBudgetsResetAtThePeriodBoundary(t *testing.T) {
b := NewBudgets()
now := time.Date(2026, 1, 31, 23, 59, 0, 0, time.UTC)
+13
View File
@@ -61,6 +61,19 @@ func (b *Budgets) Restore(period store.Period, spend map[string]int64) {
}
}
// Raise lifts counters to the spend given, where it is more.
func (b *Budgets) Raise(period store.Period, spend map[string]int64) {
start := PeriodStart(period, b.now())
b.mu.Lock()
defer b.mu.Unlock()
for key, micros := range spend {
if c := b.currentLocked(key, start); micros > c.micros {
c.micros = micros
b.spend[key] = c
}
}
}
// 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 {
+27 -3
View File
@@ -18,6 +18,18 @@ import (
type Price struct {
PromptPer1k int64 `koanf:"prompt_per_1k"`
CompletionPer1k int64 `koanf:"completion_per_1k"`
// CachedPer1k is what a prompt token costs that the engine or provider
// read from its cache instead of computing: the part of prompt_tokens
// the answer counts as cached_tokens. Zero charges those at
// PromptPer1k, as before there was a rate for them.
CachedPer1k int64 `koanf:"cached_per_1k"`
// PerImage is what one picture costs, for a model that makes them: a
// picture's cost is the card's time, which its prompt's few tokens do
// not measure.
PerImage int64 `koanf:"per_image"`
// PerMesh is what one 3D model costs: minutes of a card, which no
// token count measures.
PerMesh int64 `koanf:"per_mesh"`
}
// Zero reports whether this model spends nothing.
@@ -25,14 +37,26 @@ type Price struct {
// 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 }
func (p Price) Zero() bool {
return p.PromptPer1k == 0 && p.CompletionPer1k == 0 && p.CachedPer1k == 0 && p.PerImage == 0 && p.PerMesh == 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)
//
// cachedTokens are counted within promptTokens, as OpenAI's usage counts
// them, and are charged at the cached rate instead of the prompt rate.
func (p Price) Cost(promptTokens, cachedTokens, completionTokens, images, meshes int64) int64 {
cachedTokens = min(max(cachedTokens, 0), max(promptTokens, 0))
cachedRate := p.CachedPer1k
if cachedRate == 0 {
cachedRate = p.PromptPer1k
}
return divUp((promptTokens-cachedTokens)*p.PromptPer1k+cachedTokens*cachedRate, 1000) +
divUp(completionTokens*p.CompletionPer1k, 1000) +
max(images, 0)*p.PerImage + max(meshes, 0)*p.PerMesh
}
func divUp(n, d int64) int64 {
+10 -3
View File
@@ -25,7 +25,11 @@ type Limiter struct {
type window struct {
// current and previous are counts in this minute and the one before.
current, previous int64
start time.Time
// othersCurrent and othersPrevious are the same counts on the other
// control planes taking requests, as last heard: what makes a limit the
// cluster's rather than each process's.
othersCurrent, othersPrevious int64
start time.Time
}
// NewLimiter builds an empty limiter.
@@ -104,21 +108,24 @@ func (w *window) roll(now time.Time) {
return
case start.Sub(w.start) == windowLen:
w.previous, w.current = w.current, 0
w.othersPrevious, w.othersCurrent = w.othersCurrent, 0
default:
// More than one window has passed with no traffic.
w.previous, w.current = 0, 0
w.othersPrevious, w.othersCurrent = 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 {
cur, prev := w.current+w.othersCurrent, w.previous+w.othersPrevious
elapsed := now.Sub(w.start)
if elapsed >= windowLen {
return float64(w.current)
return float64(cur)
}
remaining := 1 - float64(elapsed)/float64(windowLen)
return float64(w.previous)*remaining + float64(w.current)
return float64(prev)*remaining + float64(cur)
}
// Forget drops a key's window. Used when a key is revoked; keeping it would
+110
View File
@@ -0,0 +1,110 @@
package accounting
import "time"
// Counts are one limiter's counts by counter: this minute's and the one
// before, as absolute figures, so reporting them twice changes nothing.
type Counts struct {
Minute time.Time `json:"minute"`
Current map[string]int64 `json:"current,omitempty"`
Previous map[string]int64 `json:"previous,omitempty"`
}
// Add sums other into c, aligned to c's minute: counts of a minute that is
// c's previous one land there, older ones are dropped. Clocks a second apart
// at a minute's turn are why.
func (c *Counts) Add(other Counts) {
switch {
case other.Minute.Equal(c.Minute):
c.Current = addInto(c.Current, other.Current)
c.Previous = addInto(c.Previous, other.Previous)
case other.Minute.Add(windowLen).Equal(c.Minute):
c.Previous = addInto(c.Previous, other.Current)
}
}
func addInto(dst, src map[string]int64) map[string]int64 {
if len(src) == 0 {
return dst
}
if dst == nil {
dst = make(map[string]int64, len(src))
}
for k, v := range src {
dst[k] += v
}
return dst
}
// Counts reads this limiter's own counts, without the others'.
func (l *Limiter) Counts() Counts {
now := l.now()
out := Counts{Minute: now.Truncate(windowLen), Current: map[string]int64{}, Previous: map[string]int64{}}
l.mu.Lock()
defer l.mu.Unlock()
for k, w := range l.keys {
w.roll(now)
if w.current > 0 {
out.Current[k] = w.current
}
if w.previous > 0 {
out.Previous[k] = w.previous
}
}
return out
}
// SetOthers replaces what the other control planes have counted. A counter
// they did not mention has nothing from them.
func (l *Limiter) SetOthers(c Counts) {
now := l.now()
aligned := Counts{Minute: now.Truncate(windowLen)}
aligned.Add(c)
l.mu.Lock()
defer l.mu.Unlock()
for k, w := range l.keys {
w.roll(now)
w.othersCurrent, w.othersPrevious = aligned.Current[k], aligned.Previous[k]
delete(aligned.Current, k)
delete(aligned.Previous, k)
}
// Counters only others have used yet: a key spread over the cluster is
// limited here from its first request.
for k := range aligned.Current {
w := l.windowLocked(k, now)
w.othersCurrent, w.othersPrevious = aligned.Current[k], aligned.Previous[k]
delete(aligned.Previous, k)
}
for k, v := range aligned.Previous {
l.windowLocked(k, now).othersPrevious = v
}
}
// Rates are an accountant's two limiters' counts.
type Rates struct {
Requests Counts `json:"requests"`
Tokens Counts `json:"tokens"`
}
// Add sums other into r.
func (r *Rates) Add(other Rates) {
r.Requests.Add(other.Requests)
r.Tokens.Add(other.Tokens)
}
// NewRates is an empty sum for the minute now is in.
func NewRates(now time.Time) Rates {
m := now.Truncate(windowLen)
return Rates{Requests: Counts{Minute: m}, Tokens: Counts{Minute: m}}
}
// Rates reads this process's own request and token counts.
func (a *Accountant) Rates() Rates {
return Rates{Requests: a.requests.Counts(), Tokens: a.tokens.Counts()}
}
// SetOtherRates puts the other control planes' counts in force here.
func (a *Accountant) SetOtherRates(r Rates) {
a.requests.SetOthers(r.Requests)
a.tokens.SetOthers(r.Tokens)
}
+71
View File
@@ -0,0 +1,71 @@
package accounting
import (
"testing"
"time"
)
// Two control planes each allowing a key six of its ten a minute: once each
// has heard the other's count, neither allows another.
func TestALimitIsTheClustersOnceTheCountsAreShared(t *testing.T) {
now := time.Date(2026, 11, 1, 12, 0, 10, 0, time.UTC)
a, b := NewLimiter(), NewLimiter()
a.now, b.now = func() time.Time { return now }, func() time.Time { return now }
for range 6 {
if !a.Allow("k", 10, 1) || !b.Allow("k", 10, 1) {
t.Fatal("refused within the limit")
}
}
a.SetOthers(b.Counts())
b.SetOthers(a.Counts())
if a.Allow("k", 10, 1) || b.Allow("k", 10, 1) {
t.Error("twelve of ten allowed once both knew")
}
// Reporting the same counts again changes nothing: they are absolute.
a.SetOthers(b.Counts())
if got := a.Rate("k"); got != 12 {
t.Errorf("rate %v after the same counts twice", got)
}
// What the others counted is never charged back to them.
if c := a.Counts(); c.Current["k"] != 6 {
t.Errorf("own count %d", c.Current["k"])
}
}
// A key only the others have used is limited here from its first request,
// and their counts move into the previous minute with the clock.
func TestOthersCountsFollowTheMinute(t *testing.T) {
now := time.Date(2026, 11, 1, 12, 0, 50, 0, time.UTC)
a, b := NewLimiter(), NewLimiter()
a.now, b.now = func() time.Time { return now }, func() time.Time { return now }
for range 10 {
b.Allow("k", 10, 1)
}
a.SetOthers(b.Counts())
if a.Allow("k", 10, 1) {
t.Error("a key the cluster has used up was allowed where it had not been seen")
}
now = now.Add(20 * time.Second) // 12:01:10, a sixth into the next minute
if got := a.Rate("k"); got < 8 || got > 8.5 {
t.Errorf("rate %v: the others' last minute should weigh five sixths", got)
}
// Counts of a minute ago, sent late, land in the previous minute.
old := b.Counts()
old.Minute = now.Truncate(time.Minute).Add(-time.Minute)
old.Current, old.Previous = map[string]int64{"k": 3}, nil
a.SetOthers(old)
if got := a.Rate("k"); got < 2 || got > 3 {
t.Errorf("rate %v from late counts", got)
}
}
func TestRatesAddAlignsMinutes(t *testing.T) {
m := time.Date(2026, 11, 1, 12, 1, 0, 0, time.UTC)
sum := NewRates(m)
sum.Add(Rates{Requests: Counts{Minute: m, Current: map[string]int64{"k": 2}, Previous: map[string]int64{"k": 1}}})
sum.Add(Rates{Requests: Counts{Minute: m.Add(-time.Minute), Current: map[string]int64{"k": 5}}})
sum.Add(Rates{Requests: Counts{Minute: m.Add(-2 * time.Minute), Current: map[string]int64{"k": 100}}})
if sum.Requests.Current["k"] != 2 || sum.Requests.Previous["k"] != 6 {
t.Errorf("sum %+v", sum.Requests)
}
}
+91
View File
@@ -0,0 +1,91 @@
package accounting
import (
"math"
"slices"
)
// Prices from what the cluster costs: what each model held of it (its
// replicas' GPU- and core-hours) at what an hour of a GPU or a core costs
// the operator, spread over the tokens it served. Idle time is in it: a
// model held all month and used an hour costs what it held, not the hour.
//
// An output token is priced OutputPerInput input tokens, as producing one
// is that much dearer (one step a token, against a prompt read in one
// pass); a cached prompt token a tenth of an input one.
const (
OutputPerInput = 4
CachedPerInput = 0.1
)
// Basis is what an hour costs, in the units budgets are in.
type Basis struct {
GPUHour float64
CoreHour float64
}
// Held is what a model held, and Served what it answered with.
type Held struct {
GPUSeconds, CoreSeconds int64
}
// Served is a model's tokens.
type Served struct {
Prompt, Completion int64
}
// Suggestion is one model's price from its cost.
type Suggestion struct {
Model string `json:"model"`
GPUHours float64 `json:"gpu_hours"`
CoreHours float64 `json:"core_hours"`
// Cost is what it held cost, in budget units.
Cost float64 `json:"cost"`
Prompt int64 `json:"prompt_tokens"`
Completion int64 `json:"completion_tokens"`
// Price is the suggestion; zero with a Note saying why there is none.
Price Price `json:"price"`
Note string `json:"note,omitempty"`
}
// Suggest prices every model that held something or served something.
func Suggest(held map[string]Held, served map[string]Served, b Basis) []Suggestion {
var names []string
for m := range held {
names = append(names, m)
}
for m := range served {
if _, ok := held[m]; !ok {
names = append(names, m)
}
}
slices.Sort(names)
out := make([]Suggestion, 0, len(names))
for _, m := range names {
h, s := held[m], served[m]
g := Suggestion{Model: m, GPUHours: float64(h.GPUSeconds) / 3600, CoreHours: float64(h.CoreSeconds) / 3600,
Prompt: s.Prompt, Completion: s.Completion}
g.Cost = g.GPUHours*b.GPUHour + g.CoreHours*b.CoreHour
// Tokens counted as output ones: an input token is a fraction of one.
units := float64(s.Completion) + float64(s.Prompt)/OutputPerInput
switch {
case g.Cost == 0 && units > 0:
g.Note = "held nothing recorded here: an outside provider's, or served before the hours were kept"
case g.Cost > 0 && units == 0:
g.Note = "held the cluster and was billed no tokens (a guard's judge, or a model priced by the picture or 3D model): no one pays what it held"
case g.Cost > 0:
// Micro-units per thousand output tokens, rounded up so a
// cheap model is never free.
out1k := int64(math.Ceil(g.Cost / units * 1000 * 1e6))
g.Price = Price{
CompletionPer1k: out1k,
PromptPer1k: ceilDiv(out1k, OutputPerInput),
CachedPer1k: max(int64(math.Ceil(float64(ceilDiv(out1k, OutputPerInput))*CachedPerInput)), 1),
}
}
out = append(out, g)
}
return out
}
func ceilDiv(a, b int64) int64 { return (a + b - 1) / b }
+45
View File
@@ -0,0 +1,45 @@
package accounting
import (
"strings"
"testing"
)
func TestSuggestSpreadsWhatAModelHeldOverItsTokens(t *testing.T) {
// The worked example: two cards for 1000 hours at 1.35 an hour is 2700;
// 3.0 billion output tokens and 4.0 billion input ones, counted as 1.0
// billion output ones, are 4.0 billion: 0.675 a million output tokens.
held := map[string]Held{
"llama-70b": {GPUSeconds: 2 * 1000 * 3600},
"idle": {GPUSeconds: 3600},
"small-cpu": {CoreSeconds: 8 * 100 * 3600},
}
served := map[string]Served{
"llama-70b": {Prompt: 4_000_000_000, Completion: 3_000_000_000},
"small-cpu": {Prompt: 1_000_000, Completion: 1_000_000},
"outside": {Prompt: 10, Completion: 10},
}
got := Suggest(held, served, Basis{GPUHour: 1.35, CoreHour: 0.01})
by := map[string]Suggestion{}
for _, s := range got {
by[s.Model] = s
}
l := by["llama-70b"]
if l.Cost != 2700 || l.Price.CompletionPer1k != 675 || l.Price.PromptPer1k != 169 || l.Price.CachedPer1k != 17 {
t.Errorf("llama: %+v", l)
}
// What it costs back at that price is what it held, within the rounding.
back := float64(l.Price.Cost(l.Prompt, 0, l.Completion, 0, 0)) / 1e6
if back < l.Cost || back > l.Cost*1.01 {
t.Errorf("charged %.2f for a cost of %.2f", back, l.Cost)
}
if s := by["small-cpu"]; s.Cost != 8 || s.Price.CompletionPer1k == 0 {
t.Errorf("cpu: %+v", s)
}
if s := by["idle"]; !s.Price.Zero() || !strings.Contains(s.Note, "billed no tokens") {
t.Errorf("idle: %+v", s)
}
if s := by["outside"]; !s.Price.Zero() || !strings.Contains(s.Note, "held nothing") {
t.Errorf("outside: %+v", s)
}
}
+307 -18
View File
@@ -6,11 +6,13 @@ import (
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"maps"
"math"
"math/rand/v2"
"net"
"net/http"
@@ -33,9 +35,12 @@ import (
"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/slurmnode"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/train"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
"gitea.mynodes.xyz/ilker/llmgw/internal/kvevents"
"gitea.mynodes.xyz/ilker/llmgw/internal/obs"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
@@ -43,7 +48,9 @@ import (
// Agent runs on a GPU node.
type Agent struct {
cfg Config
// upgrading is true while an upgrade downloads.
upgrading atomic.Bool
cfg Config
// answered says the current session heard from its control plane, which
// starts the reconnect backoff again when it ends.
answered atomic.Bool
@@ -87,7 +94,14 @@ type Agent struct {
// serverList is every control plane to dial: the configured ones, and
// any the leader has named since. Read through servers().
serverList []string
inv discovery.Inventory
// fwMu keeps one firewall change at a time; fwRunner runs its scripts
// (nil: this machine's shell).
fwMu sync.Mutex
fwRunner firewall.Runner
// fwLast is the last firewall message, applied again when the node
// is given a part in Slurm. Under fwMu.
fwLast *llmgwv1.Firewall
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
@@ -131,6 +145,17 @@ type Agent struct {
// by mu.
kv *kvevents.Mirror
kvSubs map[string]*kvevents.Subscriber
// settingsCheck says whether the cluster's settings for this agent
// would load beside its agent.yaml; see SetSettingsCheck.
settingsCheck func([]byte) error
// trainer runs LoRA training jobs, one at a time (BACKLOG F4).
trainer *train.Runner
// slurm keeps the node's part in Slurm (BACKLOG P); slurmOut is where
// its status goes, under mu.
slurm *slurmnode.Keeper
slurmOut chan<- *llmgwv1.AgentMessage
}
// connectHTTPClient is the HTTP client connect uses; an interface so tests can
@@ -170,13 +195,26 @@ func New(cfg Config, httpClient connectHTTPClient, log *slog.Logger) (*Agent, er
logRing: ring,
provKick: make(chan struct{}, 1),
load: discovery.NewLoadSampler(),
sensors: discovery.NewSensorSampler(),
sensors: discovery.NewSensorSampler().WithProfile(discovery.NewProfSampler(cfg.Discovery.DCGMExporter)),
wear: discovery.NewHealthSampler(),
serverList: loadControlPlanes(cfg.Servers, cfg.TLS.Enabled()),
kv: kvevents.NewMirror(),
kvSubs: map[string]*kvevents.Subscriber{},
}
a.prov = &provision.Installer{Root: cfg.EnginesDir, Log: log, OnStatus: a.provisionStatus}
runAs := cfg.Superv.RunAs
if runAs == "" {
runAs = supervisor.DefaultRunAs
}
// Where the engines are, as the installer has it when the file says
// nothing.
enginesDir := cfg.EnginesDir
if enginesDir == "" {
enginesDir = provision.DefaultRoot
}
a.trainer = train.New(train.Env{EnginesDir: enginesDir, WorkDir: filepath.Join(cfg.Superv.WorkDir, "trainer"),
RunAs: runAs, Log: log})
a.slurm = slurmnode.New(slurmnode.Local{}, a.prov, slurmMarker, log)
return a, nil
}
@@ -203,6 +241,7 @@ func (a *Agent) Run(ctx context.Context) error {
}
go a.provisionLoop(ctx)
go a.ensureDiags(ctx)
go a.slurm.Run(ctx, a.sendSlurm)
attempt := 0
next := 0
@@ -366,6 +405,15 @@ func (a *Agent) settleAdopted(sh *supervisor.Shard, o supervisor.Orphan) {
_ = a.sup.Stop(ctx, o.ShardID)
return
}
// What it computes on, for one started before the agent kept that — or
// kept as the CPU for a GPU placement by an agent that did not follow
// an engine into a process group of its own, which the check above has
// just found on its GPUs.
if c := a.sup.ComputeOf(o.ShardID); c == nil || (len(o.Spec.GPUIndices) > 0 && c.Backend == "CPU") {
if c, ok := a.gpuCompute(ctx, o.Spec.GPUIndices, sh.Status().PID); ok {
a.sup.SetCompute(o.ShardID, c)
}
}
a.log.Info("adopted engine is serving",
"shard", o.ShardID, "replica", o.ReplicaID)
}
@@ -406,6 +454,17 @@ func (a *Agent) weights() *weights.Fetcher {
}
// runningShards describes what this agent is supervising, for registration.
// unready are the shards a registration reported as not yet ready.
func unready(running []*llmgwv1.RunningShard) map[string]bool {
out := map[string]bool{}
for _, r := range running {
if r.GetState() != agentsvc.StateToWire(supervisor.Ready) {
out[r.GetShardId()] = true
}
}
return out
}
func (a *Agent) runningShards() []*llmgwv1.RunningShard {
var out []*llmgwv1.RunningShard
for _, st := range a.sup.Statuses() {
@@ -431,6 +490,7 @@ func (a *Agent) runningShards() []*llmgwv1.RunningShard {
Pool: spec.Pool,
Fabric: spec.Fabric,
ProfileFp: spec.ProfileFP,
Compute: computeToWire(a.sup.ComputeOf(st.ID)),
LeaderAddr: a.cfg.MgmtAddr,
LeaderPort: int32(spec.LeaderPort),
})
@@ -469,6 +529,8 @@ func (a *Agent) scan(ctx context.Context) error {
inv, err := discovery.Discover(ctx, opts, a.log)
host := discovery.DiscoverHost()
host.MinComputeCapability = discovery.MinComputeCapability(inv.GPUs)
bw := a.memBandwidth()
host.MemBandwidthGBs, host.MemBandwidthCoreGBs = bw.All, bw.Core
a.mu.Lock()
a.inv = inv
a.prov.Host = host
@@ -478,6 +540,18 @@ func (a *Agent) scan(ctx context.Context) error {
return err
}
// forgetSent is a new stream: a control plane that knows nothing of where
// installs stand, nor which files are here beyond what registration says —
// and registration says what the startup scan found. So the next heartbeat
// tells it both again. Without it, files deleted after the agent started came
// back on a control plane that restarted, and stayed until the next change.
func (a *Agent) forgetSent() {
a.mu.Lock()
defer a.mu.Unlock()
a.provSent = ""
a.lastWeights = ""
}
// session runs one connection until it breaks.
func (a *Agent) session(ctx context.Context, server string) error {
client := llmgwv1connect.NewAgentServiceClient(a.http, server)
@@ -514,17 +588,19 @@ func (a *Agent) session(ctx context.Context, server string) error {
reg.EngineVersions = a.engines(parent)
reg.EnginesProbed = true
reg.InstallableEngines = slices.DeleteFunc(provision.Installable(), a.disabled)
reg.Features = []string{FeatureFabricTest}
reg.Features = []string{FeatureFabricTest, FeatureSlurm}
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()
a.forgetSent()
reg.Host = &llmgwv1.Host{
OsId: h.OSID, OsVersion: h.OSVersion, OsCodename: h.OSCodename,
Arch: h.Arch, CpuFlags: h.CPUFlags, NvidiaDriver: h.NVIDIADriver,
// What is measured by now, not what the startup scan found before
// the measurement ended: a reconnect would otherwise say 0.
MemBandwidthGbs: a.memBandwidth().All,
MemBandwidthCoreGbs: a.memBandwidth().Core,
}
if err := stream.Send(&llmgwv1.AgentMessage{
@@ -559,8 +635,11 @@ func (a *Agent) session(ctx context.Context, server string) error {
a.sendLoop(ctx, stream, out)
}()
defer wg.Wait()
go a.watchExits(ctx, out)
go a.watchExits(ctx, out, unready(reg.RunningShards))
go a.forwardKV(ctx, out)
go a.reportCompute(ctx, out)
a.setSlurmOut(out)
defer a.setSlurmOut(nil)
for {
msg, err := stream.Receive()
@@ -621,6 +700,8 @@ func (a *Agent) heartbeat() *llmgwv1.AgentMessage {
// without anybody restarting anything.
hb.MemoryTotalMb = int64(discovery.MemoryTotalMB())
hb.CpuCount = int32(runtime.NumCPU())
bw := a.memBandwidth()
hb.MemBandwidthGbs, hb.MemBandwidthCoreGbs = bw.All, bw.Core
a.reprobeEngines()
a.mu.Lock()
@@ -742,6 +823,11 @@ func (a *Agent) sampleLoad() *llmgwv1.NodeLoad {
MemoryUsedMb: g.MemoryUsedMB, MemoryTotalMb: g.MemoryTotalMB,
SmClockMhz: g.SMClockMHz, EccUncorrected: g.ECCUncorrected,
}
if p := g.Profile; p != nil {
gs.HasProfile, gs.ProfileSource = true, p.Source
gs.SmActivePercent, gs.SmOccupancyPercent = p.SMActive, p.SMOccupancy
gs.TensorActivePercent, gs.DramActivePercent = p.TensorActive, p.DRAMActive
}
if w, ok := wear[g.Index]; ok {
gs.HasWear = true
gs.PcieReplays, gs.RetiredPagesSbe, gs.RetiredPagesDbe, gs.RetirementPending =
@@ -820,10 +906,27 @@ func (a *Agent) removeWeights(paths []string) {
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 set — every file of one version, fetched into a directory of its
// own — goes whole: os.Remove refused it as not empty, and nothing
// was freed of what a model of several files left. Lstat, so a link
// is taken away and never what it points at.
info, err := os.Lstat(abs)
switch {
case os.IsNotExist(err):
continue
case err != nil:
a.log.Warn("could not look at cached weights", "path", p, "err", err)
continue
case info.IsDir():
err = os.RemoveAll(abs)
default:
err = os.Remove(abs)
}
if err != nil {
a.log.Warn("could not remove cached weights", "path", p, "err", err)
continue
}
_ = os.Remove(weights.ChecksumPath(abs))
a.log.Info("removed cached weights no model asks for", "path", p)
}
}
@@ -854,6 +957,24 @@ func (a *Agent) handle(ctx context.Context, msg *llmgwv1.ControlMessage, out cha
go a.upgrade(ctx, m.Upgrade, out)
case *llmgwv1.ControlMessage_ControlPlanes:
a.setControlPlanes(m.ControlPlanes)
case *llmgwv1.ControlMessage_Firewall:
go a.applyFirewall(m.Firewall)
case *llmgwv1.ControlMessage_AgentSettings:
go a.applySettings(m.AgentSettings)
case *llmgwv1.ControlMessage_TrainStart:
go a.trainJob(ctx, m.TrainStart, out)
case *llmgwv1.ControlMessage_Slurm:
a.slurmMessage(m.Slurm)
case *llmgwv1.ControlMessage_TrainCancel:
a.trainer.Cancel(m.TrainCancel.GetJobId())
case *llmgwv1.ControlMessage_RestartAgent:
go func() {
a.log.Info("restarting, as the control plane asks; the engines keep serving",
"reason", m.RestartAgent.GetReason())
// A moment for the log line to leave before the process does.
time.Sleep(300 * time.Millisecond)
exitForUpgrade()
}()
case *llmgwv1.ControlMessage_Logs:
reply := a.logsReply(m.Logs)
// Off the receive loop: a full send queue must not stop commands being
@@ -1059,11 +1180,11 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
send(&llmgwv1.ShardStatus{
State: agentsvc.StateToWire(supervisor.Starting),
Fetch: &llmgwv1.FetchProgress{
Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal,
Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal, Phase: p.Phase,
},
})
})
if path, err := a.weights().Ensure(ctx, spec.Profile.ModelPath); err != nil {
if path, err := a.weights().EnsureChecked(ctx, spec.Profile.ModelPath, spec.Profile.AllowPickle, spec.Profile.ModelSHA256); err != nil {
stop()
report(supervisor.Failed, err.Error(), nil)
return
@@ -1071,11 +1192,37 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
stop()
spec.Profile.ModelPath = path
}
// A shared ComfyUI replica's members' weights, each fetched like the
// model's, and handed to the gate where they landed.
if raw := spec.Profile.Env[engines.SharedModelsEnv]; raw != "" {
var ms []map[string]string
if err := json.Unmarshal([]byte(raw), &ms); err != nil {
report(supervisor.Failed, "the shared replica's models are not readable: "+err.Error(), nil)
return
}
for i, m := range ms {
stop := a.watchFetch(m["source"], func(p weights.Progress) {
send(&llmgwv1.ShardStatus{State: agentsvc.StateToWire(supervisor.Starting),
Fetch: &llmgwv1.FetchProgress{Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal,
Phase: p.Phase}})
})
path, err := a.weights().Ensure(ctx, m["source"], m["allow_pickle"] == "true")
stop()
if err != nil {
report(supervisor.Failed, fmt.Sprintf("model %s: %v", m["name"], err), nil)
return
}
ms[i]["path"] = path
}
b, _ := json.Marshal(ms)
spec.Profile.Env = maps.Clone(spec.Profile.Env)
spec.Profile.Env[engines.SharedModelsEnv] = string(b)
}
// Adapters are fetched like the model, and before the engine starts: it
// loads them at start, and one missing is a replica that answers for the
// base model under the adapter's name.
for i, ad := range spec.Profile.Adapters {
path, err := a.weights().Ensure(ctx, ad.Path)
path, err := a.weights().Ensure(ctx, ad.Path, spec.Profile.AllowPickle)
if err != nil {
report(supervisor.Failed, fmt.Sprintf("adapter %s: %v", ad.Name, err), nil)
return
@@ -1123,6 +1270,14 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
} else {
err = a.sup.WaitAlive(ctx, sh)
}
// Where it put the model, by its own account: said with the readiness,
// and a GPU placement it computes on the CPU refused even where
// nvidia-smi cannot be asked.
compute, said, cerr := engineCompute(eng, spec.GPUIndices, sh.Logs(512))
if err == nil && cerr != nil {
err = cerr
_ = a.sup.Stop(ctx, shardID)
}
if err == nil {
err = a.onItsGPUs(ctx, eng, spec, sh.Status().PID)
if err != nil {
@@ -1135,7 +1290,19 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
report(supervisor.Failed, err.Error(), sh.Logs(20))
return
}
report(supervisor.Ready, "", nil)
// The engine's own account when it gave one, with its layers; else what
// nvidia-smi sees the shard holding.
if !said {
compute, said = a.gpuCompute(ctx, spec.GPUIndices, sh.Status().PID)
}
if said {
// Kept with the shard, so an agent restarted, or a control plane
// taking over, still learns it.
a.sup.SetCompute(shardID, compute)
send(&llmgwv1.ShardStatus{State: agentsvc.StateToWire(supervisor.Ready), Compute: computeToWire(&compute)})
} else {
report(supervisor.Ready, "", nil)
}
// Its dying later is reported by the session's watchExits.
}
@@ -1148,7 +1315,14 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
// reported the engine dying; and an adopted engine had no watcher at all —
// on node06 one was killed and its replica stayed "ready" for the control
// plane, routed to and failing, indefinitely.
func (a *Agent) watchExits(ctx context.Context, out chan<- *llmgwv1.AgentMessage) {
//
// It also reports the shards the registration said were not ready yet once
// they are. An adopted engine is health-checked after the agent registers,
// and a shard started on the stream before is reported on that stream; so
// when the registration came first nothing ever told the control plane, and
// the replica stayed "starting" while it served — on node05, after a
// control plane restart, the only replica of a model.
func (a *Agent) watchExits(ctx context.Context, out chan<- *llmgwv1.AgentMessage, pending map[string]bool) {
t := time.NewTicker(time.Second)
defer t.Stop()
ready := map[string]bool{}
@@ -1157,6 +1331,24 @@ func (a *Agent) watchExits(ctx context.Context, out chan<- *llmgwv1.AgentMessage
switch st.State {
case supervisor.Ready:
ready[st.ID] = true
if !pending[st.ID] {
continue
}
delete(pending, st.ID)
sh, err := a.sup.Get(st.ID)
if err != nil {
continue
}
msg := &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Shard{Shard: &llmgwv1.ShardStatus{
NodeId: a.cfg.NodeID, ReplicaId: sh.Spec.ReplicaID, ShardId: st.ID,
State: agentsvc.StateToWire(st.State),
Compute: computeToWire(a.sup.ComputeOf(st.ID)),
}}}
select {
case out <- msg:
case <-ctx.Done():
return
}
case supervisor.Failed:
if !ready[st.ID] {
continue // never ready here: its start reported it
@@ -1286,11 +1478,14 @@ func Client(cfg tlsconf.Config, timeout time.Duration) (*http.Client, error) {
// off — is never closed: nothing sends the FIN, and the agent's heartbeats sit
// in the socket's buffer until the kernel gives up retransmitting, fifteen
// minutes later. Found by switching off a leader: the standby took over in 14
// seconds and the node stayed with the dead one. An HTTP/2 ping after ten
// quiet seconds, given five to answer, finds it in a quarter of a minute.
// seconds and the node stayed with the dead one. An HTTP/2 ping after five
// quiet seconds, given three to answer, finds it in eight: by the time the
// standby leads, as it does within the lease's ten seconds, the node is on
// its way to it. At fifteen, with the lease at fifteen too, the new leader
// went without its nodes, and so its judges, for half a minute.
const (
streamIdle = 10 * time.Second
streamPing = 5 * time.Second
streamIdle = 5 * time.Second
streamPing = 3 * time.Second
)
// localizeSpec fills in what only this node knows.
@@ -1366,3 +1561,97 @@ func ResolveManagement(c Config) (addr, iface string) {
}
return addr, iface
}
// reportCompute tells the control plane, once a session, what each running
// shard computes on as soon as that is known: an adopted engine's is found
// after registration, which carried what was known then.
func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMessage) {
// What was sent for each shard, so a compute that changes is sent again:
// an adopted shard's kept one is sent at once, and adopting it may then
// find it was kept wrong.
sent := map[string]string{}
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for range 60 {
for _, st := range a.sup.Statuses() {
if st.State.Terminal() {
continue
}
c := a.sup.ComputeOf(st.ID)
if c == nil {
continue
}
said := fmt.Sprint(*c)
if sent[st.ID] == said {
continue
}
sh, err := a.sup.Get(st.ID)
if err != nil {
continue
}
// No state: a report of compute alone, not a transition.
msg := &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Shard{Shard: &llmgwv1.ShardStatus{
NodeId: a.cfg.NodeID, ReplicaId: sh.Spec.ReplicaID, ShardId: st.ID, Compute: computeToWire(c),
}}}
select {
case out <- msg:
sent[st.ID] = said
case <-ctx.Done():
return
}
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
// memBandwidth is the node's memory read rate in GB/s, 0 until measured.
// Measured in the background, the first time it is asked for: it reads half
// a gigabyte, which must not hold up an agent reaching its control plane,
// and the heartbeat carries it when it is known. Not at once but after a
// minute or so, chosen at random, and again every hour, keeping the best:
// VMs on one host that all restart together measure each other, and read a
// third less than any of them gets alone. Once a process rather than an
// agent, so tests running many agents in one read it once.
func (a *Agent) memBandwidth() discovery.Bandwidth {
bwOnce.Do(func() {
go func() {
time.Sleep(bandwidthDelay())
for {
// The best of each seen so far: an hourly pass on a node
// busy serving reads less, and that is not the memory.
bw := measureBandwidth()
cur := a.memBandwidthNow()
if bw.All > cur.All || bw.Core > cur.Core {
bwBits.Store(math.Float64bits(max(bw.All, cur.All)))
coreBits.Store(math.Float64bits(max(bw.Core, cur.Core)))
a.log.Info("this node's memory", "read_gbs", math.Round(bw.All*10)/10,
"one_core_gbs", math.Round(bw.Core*10)/10)
}
time.Sleep(time.Hour)
}
}()
})
return a.memBandwidthNow()
}
// memBandwidthNow is what has been measured so far, without starting a
// measurement.
func (a *Agent) memBandwidthNow() discovery.Bandwidth {
return discovery.Bandwidth{All: math.Float64frombits(bwBits.Load()), Core: math.Float64frombits(coreBits.Load())}
}
// bandwidthDelay is how long an agent waits before measuring, so agents
// started together measure apart.
var bandwidthDelay = func() time.Duration { return 10*time.Second + rand.N(110*time.Second) }
var (
bwOnce sync.Once
bwBits atomic.Uint64
coreBits atomic.Uint64
// measureBandwidth is replaced in tests, which have no use for it.
measureBandwidth = discovery.MeasureMemoryBandwidth
)
+34 -7
View File
@@ -37,11 +37,12 @@ import (
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
mu sync.Mutex
calls []adoptCall
adopted []reconciler.AdoptedShard
unclaim []string // shard ids to report back as unclaimed
fetches []fetchReport
computes []reconciler.Compute
}
type status struct {
@@ -61,7 +62,13 @@ func (r *statusRecorder) OnShardStatus(replicaID, shardID string, st supervisor.
// 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) {
func (r *statusRecorder) OnShardCompute(replicaID, shardID string, c reconciler.Compute) {
r.mu.Lock()
defer r.mu.Unlock()
r.computes = append(r.computes, c)
}
func (r *statusRecorder) OnFetchProgress(replicaID, shardID, source, phase string, done, total int64) {
r.mu.Lock()
defer r.mu.Unlock()
r.fetches = append(r.fetches, fetchReport{replicaID, shardID, source, done, total})
@@ -664,7 +671,7 @@ func TestAShardWithMissingWeightsFailsBeforeLaunching(t *testing.T) {
// 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"))
_, err := a.weights().Ensure(t.Context(), filepath.Join(t.TempDir(), "not-there.gguf"), false)
if err == nil {
t.Fatal("a missing weights file was accepted")
}
@@ -918,3 +925,23 @@ func TestABandwidthTestIsRunOnTheAdapterAskedFor(t *testing.T) {
t.Errorf("reply %+v, ran %+v", r, got)
}
}
func TestANewStreamIsToldTheFilesAgain(t *testing.T) {
// Registration carries what the startup scan found; a file deleted since
// came back on a control plane that restarted, because the next
// heartbeat saw the list unchanged since it was last sent.
dir := t.TempDir()
h := start(t, func(c *Config) { c.ModelDirs = []string{dir} })
if err := os.WriteFile(filepath.Join(dir, "0123456789abcdef-m.gguf"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
h.ag.heartbeat()
if hb := h.ag.heartbeat().GetHeartbeat(); hb.GetWeightsChanged() {
t.Fatal("an unchanged list should not be sent again on the same stream")
}
h.ag.forgetSent()
hb := h.ag.heartbeat().GetHeartbeat()
if !hb.GetWeightsChanged() || len(hb.GetWeights()) != 1 {
t.Errorf("a new stream should be told the files: changed=%v %v", hb.GetWeightsChanged(), hb.GetWeights())
}
}
+5
View File
@@ -103,6 +103,11 @@ type Config struct {
Tracing obs.Tracing `koanf:"tracing"`
Version string
// Settings is the cluster's settings for this agent it was started on,
// as the control plane sent them: what a newly sent version is
// compared with.
Settings string `koanf:"-"`
}
// DefaultConfig returns sensible defaults.
+93
View File
@@ -0,0 +1,93 @@
package discovery
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
)
// CUDAToolkit is the root of an installed CUDA toolkit of this major version
// — the directory whose bin holds nvcc — or "" when there is none. Engines
// that compile their kernels where they run (FreeToken, ComfyUI's compiled
// nodes) need one matching the CUDA their torch was built for, and a
// toolkit of another major version compiles code that torch cannot load.
//
// CUDA_HOME is looked at first, then /usr/local/cuda, then every
// /usr/local/cuda-<version>, newest first.
func CUDAToolkit(major int) string {
return cudaToolkitIn(major, os.Getenv("CUDA_HOME"), "/usr/local")
}
func cudaToolkitIn(major int, home, root string) string {
var dirs []string
if home != "" {
dirs = append(dirs, home)
}
dirs = append(dirs, filepath.Join(root, "cuda"))
versioned, _ := filepath.Glob(filepath.Join(root, "cuda-*"))
slices.SortFunc(versioned, func(a, b string) int { return versionCompare(b, a) })
dirs = append(dirs, versioned...)
for _, d := range dirs {
if _, err := os.Stat(filepath.Join(d, "bin", "nvcc")); err != nil {
continue
}
if toolkitMajor(d) == major {
return d
}
}
return ""
}
// toolkitMajor is a toolkit's major version, from the version.json every
// toolkit since 11.1 carries, or from nvcc where it has none; 0 when neither
// says.
func toolkitMajor(dir string) int {
if b, err := os.ReadFile(filepath.Join(dir, "version.json")); err == nil { //nolint:gosec // a toolkit's own file
var v struct {
CUDA struct {
Version string `json:"version"`
} `json:"cuda"`
}
if json.Unmarshal(b, &v) == nil && v.CUDA.Version != "" {
n, _ := strconv.Atoi(strings.SplitN(v.CUDA.Version, ".", 2)[0])
return n
}
}
//nolint:gosec // nvcc of a toolkit found on disk
out, err := exec.Command(filepath.Join(dir, "bin", "nvcc"), "--version").Output()
if err != nil {
return 0
}
if m := nvccRelease.FindSubmatch(out); m != nil {
n, _ := strconv.Atoi(string(m[1]))
return n
}
return 0
}
var nvccRelease = regexp.MustCompile(`release (\d+)\.`)
// versionCompare orders two paths ending in dotted versions: cuda-13.10 is
// newer than cuda-13.4, which comparing them as text gets wrong.
func versionCompare(a, b string) int {
pa := strings.Split(strings.TrimPrefix(filepath.Base(a), "cuda-"), ".")
pb := strings.Split(strings.TrimPrefix(filepath.Base(b), "cuda-"), ".")
for i := range max(len(pa), len(pb)) {
var x, y int
if i < len(pa) {
x, _ = strconv.Atoi(pa[i])
}
if i < len(pb) {
y, _ = strconv.Atoi(pb[i])
}
if x != y {
return x - y
}
}
return 0
}
@@ -0,0 +1,48 @@
package discovery
import (
"os"
"path/filepath"
"testing"
)
func toolkit(t *testing.T, root, name, version string) string {
t.Helper()
d := filepath.Join(root, name)
put(t, d, "bin/nvcc", "#!/bin/sh")
if version != "" {
put(t, d, "version.json", `{"cuda":{"name":"CUDA SDK","version":"`+version+`"}}`)
}
return d
}
func TestTheNewestToolkitOfTheMajorVersionAskedForIsFound(t *testing.T) {
root := t.TempDir()
toolkit(t, root, "cuda-12.8", "12.8.1")
toolkit(t, root, "cuda-13.4", "13.4.0")
want := toolkit(t, root, "cuda-13.10", "13.10.0")
// /usr/local/cuda is a 12: a toolkit of the wrong major version is not
// one torch built for 13 can load what it compiles.
toolkit(t, root, "cuda", "12.8.1")
if got := cudaToolkitIn(13, "", root); got != want {
t.Errorf("got %q, want %q", got, want)
}
if got := cudaToolkitIn(12, "", root); got != filepath.Join(root, "cuda") {
t.Errorf("12: got %q", got)
}
// CUDA_HOME first, when it is one.
home := toolkit(t, t.TempDir(), "mine", "13.0.2")
if got := cudaToolkitIn(13, home, root); got != home {
t.Errorf("CUDA_HOME: got %q", got)
}
if got := cudaToolkitIn(14, "", root); got != "" {
t.Errorf("14: got %q", got)
}
// A directory without nvcc is not a toolkit.
if err := os.MkdirAll(filepath.Join(root, "cuda-13.99"), 0o755); err != nil {
t.Fatal(err)
}
if got := cudaToolkitIn(13, "", root); got != want {
t.Errorf("with an empty cuda-13.99: got %q", got)
}
}
+5
View File
@@ -28,6 +28,11 @@ type Options struct {
// 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"`
// DCGMExporter is the dcgm-exporter metrics URL read for how much of
// each card's chip is in use, on cards NVML's GPM does not cover (before
// Hopper). Empty is http://127.0.0.1:9400/metrics; "off" reads none. A
// node without one costs a refused connection every five minutes.
DCGMExporter string `koanf:"dcgm_exporter"`
// ModelDirs are scanned for weights. Empty means do not look.
ModelDirs []string `koanf:"-"`
}
+6
View File
@@ -0,0 +1,6 @@
//go:build !nvml
package discovery
// newGPM has nothing to read GPM with in a build without the nvml tag.
func newGPM() gpmSource { return nil }
+124
View File
@@ -0,0 +1,124 @@
//go:build nvml
package discovery
import (
"sync"
"github.com/NVIDIA/go-nvml/pkg/nvml"
)
// nvmlGPM reads GPM, NVML's own profiling counters, on the cards that have
// them: Hopper and later. Each read takes a sample and works out the metrics
// against the one before, so NVML stays initialised between reads.
type nvmlGPM struct {
mu sync.Mutex
opened bool
failed bool
cards map[int]*gpmCard
}
type gpmCard struct {
dev nvml.Device
prev, cur nvml.GpmSample
havePrevious bool
}
func newGPM() gpmSource { return &nvmlGPM{} }
// The GPM metrics read, in the order they are asked for.
var gpmMetrics = []struct {
id nvml.GpmMetricId
set func(*GPUProfile, float64)
}{
{nvml.GPM_METRIC_SM_UTIL, func(p *GPUProfile, v float64) { p.SMActive = v }},
{nvml.GPM_METRIC_SM_OCCUPANCY, func(p *GPUProfile, v float64) { p.SMOccupancy = v }},
{nvml.GPM_METRIC_ANY_TENSOR_UTIL, func(p *GPUProfile, v float64) { p.TensorActive = v }},
{nvml.GPM_METRIC_DRAM_BW_UTIL, func(p *GPUProfile, v float64) { p.DRAMActive = v }},
}
func (g *nvmlGPM) read() map[int]GPUProfile {
g.mu.Lock()
defer g.mu.Unlock()
if g.failed {
return nil
}
if !g.opened && !g.open() {
g.failed = true
return nil
}
out := map[int]GPUProfile{}
for idx, c := range g.cards {
if c.cur.Get(c.dev) != nvml.SUCCESS {
// A card in MIG mode, or one that has gone: no sample, and the
// next must not be measured against an old one.
c.havePrevious = false
continue
}
if c.havePrevious {
if p, ok := gpmBetween(c.prev, c.cur); ok {
out[idx] = p
}
}
c.prev, c.cur = c.cur, c.prev
c.havePrevious = true
}
return out
}
// open finds the cards with GPM. False is NVML not starting, or no card
// having it, and the reader then never tries again.
func (g *nvmlGPM) open() bool {
if nvml.Init() != nvml.SUCCESS {
return false
}
g.cards = map[int]*gpmCard{}
n, ret := nvml.DeviceGetCount()
if ret != nvml.SUCCESS {
n = 0
}
for i := range n {
dev, ret := nvml.DeviceGetHandleByIndex(i)
if ret != nvml.SUCCESS {
continue
}
sup, ret := dev.GpmQueryDeviceSupport()
if ret != nvml.SUCCESS || sup.IsSupportedDevice == 0 {
continue
}
a, ret := nvml.GpmSampleAlloc()
if ret != nvml.SUCCESS {
continue
}
b, ret := nvml.GpmSampleAlloc()
if ret != nvml.SUCCESS {
_ = a.Free()
continue
}
g.cards[i] = &gpmCard{dev: dev, prev: a, cur: b}
}
if len(g.cards) == 0 {
_ = nvml.Shutdown()
return false
}
g.opened = true
return true
}
// gpmBetween is the profile over the time between two samples.
func gpmBetween(earlier, later nvml.GpmSample) (GPUProfile, bool) {
q := nvml.GpmMetricsGetType{NumMetrics: uint32(len(gpmMetrics)), Sample1: earlier, Sample2: later}
for i, m := range gpmMetrics {
q.Metrics[i].MetricId = uint32(m.id)
}
if nvml.GpmMetricsGet(&q) != nvml.SUCCESS {
return GPUProfile{}, false
}
p := GPUProfile{SMActive: -1, SMOccupancy: -1, TensorActive: -1, DRAMActive: -1, Source: "gpm"}
for i, m := range gpmMetrics {
if r := q.Metrics[i]; r.NvmlReturn == uint32(nvml.SUCCESS) && r.Value >= 0 {
m.set(&p, r.Value)
}
}
return p, true
}
+2
View File
@@ -52,6 +52,8 @@ type GPU struct {
// memory the instance's share. Utilisation stays unreported, because no
// driver measures it per instance.
MIG *MIGDevice `json:"mig,omitempty"`
// PCIe is how the card is linked to its host, zero when not read.
PCIe PCIeLink `json:"pcie,omitempty"`
}
// GPUHealth records conditions that make a GPU unsafe to schedule onto.
+13
View File
@@ -67,10 +67,23 @@ func DiscoverGPUsNVML(_ context.Context) ([]GPU, error) {
if major, minor, ret := dev.GetCudaComputeCapability(); ret == nvml.SUCCESS {
g.ComputeCapability = fmt.Sprintf("%d.%d", major, minor)
}
if v, ret := dev.GetMaxPcieLinkGeneration(); ret == nvml.SUCCESS {
g.PCIe.Gen = v
}
if v, ret := dev.GetGpuMaxPcieLinkGeneration(); ret == nvml.SUCCESS {
g.PCIe.GPUGen = v
}
if v, ret := dev.GetCurrPcieLinkWidth(); ret == nvml.SUCCESS {
g.PCIe.Width = v
}
if v, ret := dev.GetMaxPcieLinkWidth(); ret == nvml.SUCCESS {
g.PCIe.WidthMax = v
}
if pci, ret := dev.GetPciInfo(); ret == nvml.SUCCESS {
g.PCIBusID = busIDString(pci.BusId[:])
byBusID[normalizeBusID(g.PCIBusID)] = i
}
g.PCIe = withSysfs(g.PCIe, "/sys/bus/pci/devices", g.PCIBusID)
if t, ret := dev.GetTemperature(nvml.TEMPERATURE_GPU); ret == nvml.SUCCESS {
g.Health.TemperatureC = int(t)
}
+1
View File
@@ -64,6 +64,7 @@ func DiscoverGPUsSMI(ctx context.Context, binary string) ([]GPU, error) {
if err != nil || len(cards) == 0 {
return cards, err
}
readPCIe(ctx, binary, cards)
// The query lists whole cards, MIG mode or not. A card in MIG mode is
// replaced by its instances, which only -L names and only the full
// report sizes; asked only when some card could be in MIG mode, since
+24 -3
View File
@@ -3,6 +3,7 @@ package discovery
import (
"bufio"
"os"
"regexp"
"runtime"
"strconv"
"strings"
@@ -30,6 +31,14 @@ type Host struct {
// 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"`
// MemBandwidthGBs is how fast the memory reads, in GB/s, 0 when not
// measured: what decides a model's speed on the CPU. Not read here but
// measured by the agent when it starts (MeasureMemoryBandwidth).
MemBandwidthGBs float64 `json:"mem_bandwidth_gbs,omitempty"`
// MemBandwidthCoreGBs is what one core reads alone, in GB/s, 0 when not
// measured: a replica given n threads reads at about n times this, up to
// MemBandwidthGBs.
MemBandwidthCoreGBs float64 `json:"mem_bandwidth_core_gbs,omitempty"`
}
// Family is the packaging family: "deb", "rpm", or "" when neither is known.
@@ -142,17 +151,29 @@ func cpuFlagsFrom(path string) []string {
// 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.
var driverVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+(\.[0-9]+)?$`)
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 ..."
// "NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu ...", or
// for the open module, the default since 560, "NVIDIA UNIX Open Kernel
// Module for x86_64 580.82.07 Release Build ...": the first version
// after "Module", not the word after it. Read as "for", the driver was
// unknown, and every build was tried whatever driver it needs.
fields := strings.Fields(string(b))
for i, f := range fields {
if f == "Module" && i+1 < len(fields) {
return fields[i+1]
if f != "Module" {
continue
}
for _, v := range fields[i+1:] {
if driverVersion.MatchString(v) {
return v
}
}
return ""
}
return ""
}
@@ -0,0 +1,25 @@
package discovery
import (
"os"
"path/filepath"
"testing"
)
func TestNVIDIADriverFrom(t *testing.T) {
for _, tc := range []struct{ text, want string }{
{"NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu Jan 23 21:30:38 UTC 2025\nGCC version: gcc version 12.2.0\n", "570.86.15"},
{"NVRM version: NVIDIA UNIX Open Kernel Module for x86_64 580.82.07 Release Build (dvs-builder@U16-I3-B03-4-3) Wed Aug 27 00:51:12 UTC 2025\n", "580.82.07"},
{"NVRM version: NVIDIA UNIX Open Kernel Module for aarch64 580.95.05 Release Build\n", "580.95.05"},
{"NVRM version: NVIDIA UNIX x86_64 Kernel Module 535.54 Mon\n", "535.54"},
{"something else\n", ""},
} {
p := filepath.Join(t.TempDir(), "version")
if err := os.WriteFile(p, []byte(tc.text), 0o600); err != nil {
t.Fatal(err)
}
if got := nvidiaDriverFrom(p); got != tc.want {
t.Errorf("%q: got %q, want %q", tc.text, got, tc.want)
}
}
}
+169
View File
@@ -0,0 +1,169 @@
package discovery
import (
"bufio"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"sync"
"time"
)
// How fast this machine reads its memory, which on a CPU is what decides how
// fast a model answers: every token of a reply reads all the weights it uses
// once, so a node that reads 40 GB/s writes a 4 GB model's tokens about ten
// times a second, whatever its core count. Measured once when the agent
// starts, in a fraction of a second, by every CPU summing a share of a buffer
// too large for any cache — a VM sharing its host's memory with others gets
// its share, which is the number that matters for it.
// bandwidthBuffer is what is read at most; the least is eight times the
// processor's last cache, and never under minBuffer. Smaller, it would be
// the cache that was measured — some hold a gigabyte — and the figure kept
// would be too high.
const (
bandwidthBuffer = 2 << 30
minBuffer = 256 << 20
)
// Bandwidth is how fast this machine's memory reads, in GB/s: with every
// CPU reading, and with one alone. A replica given a few threads of a large
// machine reads at a few times the second, not at the first: on a server of
// many memory channels one core cannot keep more than a fraction of them
// busy.
type Bandwidth struct {
All, Core float64
}
// coreBuffer is what the one-core passes read: past any cache, and short
// enough that one core reads it in a fraction of a second.
const coreBuffer = 512 << 20
// MeasureMemoryBandwidth is the memory's read rate, the best of a few passes
// each way; zero when the machine has too little free memory to measure.
func MeasureMemoryBandwidth() Bandwidth {
size := int64(bandwidthBuffer)
// Never more than a quarter of what is free: a node already holding
// models must not be pushed into swap by a measurement. Without room
// for a buffer past the cache, none is measured: the hourly retry
// measures it when there is.
if avail := memAvailable("/proc/meminfo"); avail > 0 {
size = min(size, avail/4)
}
if size < max(minBuffer, 8*lastCacheBytes("/sys/devices/system/cpu")) {
return Bandwidth{}
}
buf := make([]uint64, size/8)
for i := range buf {
buf[i] = uint64(i)
}
workers := runtime.NumCPU()
chunk := (len(buf) + workers - 1) / workers
best := 0.0
for pass := 0; pass < 4; pass++ {
var wg sync.WaitGroup
start := time.Now()
for w := 0; w < workers; w++ {
lo, hi := w*chunk, min((w+1)*chunk, len(buf))
if lo >= hi {
continue
}
wg.Add(1)
go func(part []uint64) {
defer wg.Done()
sink(sum(part))
}(buf[lo:hi])
}
wg.Wait()
if s := time.Since(start).Seconds(); s > 0 {
best = max(best, float64(size)/s/1e9)
}
}
// And one core alone, over a part of the same buffer.
part := buf[:min(len(buf), coreBuffer/8)]
core := 0.0
for pass := 0; pass < 3; pass++ {
start := time.Now()
sink(sum(part))
if s := time.Since(start).Seconds(); s > 0 {
core = max(core, float64(len(part)*8)/s/1e9)
}
}
return Bandwidth{All: best, Core: min(core, best)}
}
// sum reads every word, eight at a time so the loop is not what limits it.
func sum(p []uint64) uint64 {
var a, b, c, d, e, f, g, h uint64
i := 0
for ; i+8 <= len(p); i += 8 {
a += p[i]
b += p[i+1]
c += p[i+2]
d += p[i+3]
e += p[i+4]
f += p[i+5]
g += p[i+6]
h += p[i+7]
}
for ; i < len(p); i++ {
a += p[i]
}
return a + b + c + d + e + f + g + h
}
// sinkValue keeps the sums alive, so the reads are not optimised away.
var sinkValue struct {
sync.Mutex
v uint64
}
func sink(v uint64) {
sinkValue.Lock()
sinkValue.v += v
sinkValue.Unlock()
}
// memAvailable is MemAvailable in bytes, 0 when unknown.
func memAvailable(path string) int64 {
f, err := os.Open(path) //nolint:gosec // a fixed system path
if err != nil {
return 0
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
fields := strings.Fields(sc.Text())
if len(fields) >= 2 && fields[0] == "MemAvailable:" {
kb, _ := strconv.ParseInt(fields[1], 10, 64)
return kb << 10
}
}
return 0
}
// lastCacheBytes is the largest cache any CPU reports, 0 when unknown.
func lastCacheBytes(root string) int64 {
paths, _ := filepath.Glob(filepath.Join(root, "cpu*", "cache", "index*", "size"))
var most int64
for _, p := range paths {
b, err := os.ReadFile(p) //nolint:gosec // under /sys
if err != nil {
continue
}
v := strings.TrimSpace(string(b))
mult := int64(1)
switch {
case strings.HasSuffix(v, "K"):
mult, v = 1<<10, strings.TrimSuffix(v, "K")
case strings.HasSuffix(v, "M"):
mult, v = 1<<20, strings.TrimSuffix(v, "M")
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
most = max(most, n*mult)
}
}
return most
}
+32
View File
@@ -0,0 +1,32 @@
package discovery
import (
"os"
"path/filepath"
"testing"
)
func TestMemoryBandwidthIsMeasured(t *testing.T) {
if testing.Short() {
t.Skip("reads half a gigabyte")
}
bw := MeasureMemoryBandwidth()
// Any machine running this reads more than a gigabyte a second and less
// than a few terabytes, and one core no more than all of them.
if bw.All < 1 || bw.All > 5000 || bw.Core < 0.5 || bw.Core > bw.All {
t.Errorf("measured %.1f GB/s, %.1f on one core", bw.All, bw.Core)
}
t.Logf("%.1f GB/s, %.1f on one core", bw.All, bw.Core)
}
func TestTheLastCacheIsReadFromSys(t *testing.T) {
root := t.TempDir()
for path, size := range map[string]string{"cpu0/cache/index0/size": "48K", "cpu0/cache/index3/size": "96M"} {
p := filepath.Join(root, path)
_ = os.MkdirAll(filepath.Dir(p), 0o755)
_ = os.WriteFile(p, []byte(size+"\n"), 0o644)
}
if got := lastCacheBytes(root); got != 96<<20 {
t.Errorf("%d", got)
}
}
+181
View File
@@ -0,0 +1,181 @@
package discovery
import (
"context"
"encoding/csv"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
)
// A card in the wrong slot: an x16 card in a slot wired x8 or x4, behind a
// riser, or in a slot of an older PCIe generation than the card's, links
// slower than it was built to, and nothing says so. A model on the card
// still decodes at the card's own memory speed, but it loads slower, a
// tensor-parallel replica over PCIe is slower, and so is anything that
// spills to the host. The driver says how the link stands and what the
// card could do; the difference is reported where the node's warnings are.
// PCIeLink is how a card is linked to its host.
type PCIeLink struct {
// Gen is the fastest generation the card and the slot together allow —
// not the current one, which drops while the card idles, to save power.
// GPUGen is the card's own fastest, 0 when the driver does not say.
Gen int `json:"gen,omitempty"`
GPUGen int `json:"gpu_gen,omitempty"`
// Width is the lanes in use now, WidthMax the card's most.
Width int `json:"width,omitempty"`
WidthMax int `json:"width_max,omitempty"`
}
// laneGBs is one lane's usable rate in GB/s, by generation.
var laneGBs = map[int]float64{1: 0.25, 2: 0.5, 3: 0.985, 4: 1.969, 5: 3.938, 6: 7.877}
// GBs is what a link of gen and width carries, in GB/s each way.
func pcieGBs(gen, width int) float64 { return laneGBs[gen] * float64(width) }
// Problem says how a card's link falls short of what the card could do,
// empty when it does not or the driver did not say.
func (p PCIeLink) Problem() string {
gen := p.Gen
if p.GPUGen > 0 && gen == 0 {
gen = p.GPUGen
}
slowGen := p.GPUGen > 0 && p.Gen > 0 && p.Gen < p.GPUGen
narrow := p.WidthMax > 0 && p.Width > 0 && p.Width < p.WidthMax
if !slowGen && !narrow {
return ""
}
could := pcieGBs(max(p.GPUGen, gen), p.WidthMax)
has := pcieGBs(gen, p.Width)
var why []string
if narrow {
why = append(why, fmt.Sprintf("x%d of its x%d lanes", p.Width, p.WidthMax))
}
if slowGen {
why = append(why, fmt.Sprintf("PCIe Gen%d of its Gen%d", p.Gen, p.GPUGen))
}
return fmt.Sprintf("linked at %s: %.1f GB/s where it could have %.1f. "+
"Check the slot (wired fewer lanes, or an older generation), a riser, or the BIOS's PCIe setting",
strings.Join(why, " and "), has, could)
}
// pcieFields are asked apart from the rest, and the card's own fastest
// generation apart again: a driver that does not know a field refuses the
// whole query, and these must not cost a node its cards.
var pcieFields = [][]string{
{"index", "pcie.link.gen.max", "pcie.link.width.current", "pcie.link.width.max", "pcie.link.gen.gpumax"},
{"index", "pcie.link.gen.max", "pcie.link.width.current", "pcie.link.width.max"},
}
// readPCIe fills each card's link from nvidia-smi, leaving it empty when
// the driver says nothing of it.
func readPCIe(ctx context.Context, binary string, cards []GPU) {
for _, fields := range pcieFields {
out, err := exec.CommandContext(ctx, binary, "--query-gpu="+strings.Join(fields, ","),
"--format=csv,noheader,nounits").Output()
if err != nil {
continue
}
links := parsePCIe(string(out))
for i := range cards {
if l, ok := links[cards[i].Index]; ok && cards[i].MIG == nil {
cards[i].PCIe = l
}
}
break
}
for i := range cards {
if cards[i].MIG == nil {
cards[i].PCIe = withSysfs(cards[i].PCIe, "/sys/bus/pci/devices", cards[i].PCIBusID)
}
}
}
// withSysfs takes the card's own lane count, and its own fastest
// generation, from the kernel: the driver's width.max is what card and slot
// allow together, so a x16 card in a slot wired x8 reads x8 of x8 there,
// while the device's max_link_width is the card's, 16.
func withSysfs(l PCIeLink, root, busID string) PCIeLink {
dev := sysfsBusID(busID)
if dev == "" {
return l
}
read := func(name string) string {
b, err := os.ReadFile(filepath.Join(root, dev, name)) //nolint:gosec // under /sys
if err != nil {
return ""
}
return strings.TrimSpace(string(b))
}
if n, err := strconv.Atoi(read("max_link_width")); err == nil && n > 0 {
l.WidthMax = n
}
if n, err := strconv.Atoi(read("current_link_width")); err == nil && n > 0 {
l.Width = n
}
if g := genOfSpeed(read("max_link_speed")); g > 0 && l.GPUGen == 0 {
l.GPUGen = g
}
return l
}
// sysfsBusID is nvidia-smi's 00000000:3B:00.0 as the kernel names it,
// 0000:3b:00.0.
func sysfsBusID(id string) string {
id = strings.ToLower(strings.TrimSpace(id))
dom, rest, ok := strings.Cut(id, ":")
if !ok || len(dom) < 4 {
return ""
}
return dom[len(dom)-4:] + ":" + rest
}
// genOfSpeed is the PCIe generation of a link speed as sysfs writes it.
func genOfSpeed(s string) int {
switch {
case strings.HasPrefix(s, "2.5 GT/s"):
return 1
case strings.HasPrefix(s, "5.0 GT/s"), strings.HasPrefix(s, "5 GT/s"):
return 2
case strings.HasPrefix(s, "8.0 GT/s"), strings.HasPrefix(s, "8 GT/s"):
return 3
case strings.HasPrefix(s, "16.0 GT/s"), strings.HasPrefix(s, "16 GT/s"):
return 4
case strings.HasPrefix(s, "32.0 GT/s"), strings.HasPrefix(s, "32 GT/s"):
return 5
case strings.HasPrefix(s, "64.0 GT/s"), strings.HasPrefix(s, "64 GT/s"):
return 6
}
return 0
}
func parsePCIe(out string) map[int]PCIeLink {
r := csv.NewReader(strings.NewReader(out))
r.TrimLeadingSpace = true
r.FieldsPerRecord = -1
rows, _ := r.ReadAll()
num := func(s string) int {
n, _ := strconv.Atoi(strings.TrimSpace(s))
return n
}
links := map[int]PCIeLink{}
for _, row := range rows {
if len(row) < 4 {
continue
}
idx, err := strconv.Atoi(strings.TrimSpace(row[0]))
if err != nil {
continue
}
l := PCIeLink{Gen: num(row[1]), Width: num(row[2]), WidthMax: num(row[3])}
if len(row) > 4 {
l.GPUGen = num(row[4])
}
links[idx] = l
}
return links
}
+54
View File
@@ -0,0 +1,54 @@
package discovery
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestACardInTheWrongSlotIsSaidSo(t *testing.T) {
for _, c := range []struct {
link PCIeLink
want string // empty: nothing to say
}{
// The PoC's Quadro RTX 6000, idle: current Gen1, but its fastest is 3,
// as the card's is, on all 16 lanes. Nothing wrong.
{PCIeLink{Gen: 3, GPUGen: 3, Width: 16, WidthMax: 16}, ""},
// An x16 card in a slot wired x8.
{PCIeLink{Gen: 4, GPUGen: 4, Width: 8, WidthMax: 16}, "x8 of its x16 lanes: 15.8 GB/s where it could have 31.5"},
// A Gen5 card in a Gen4 slot.
{PCIeLink{Gen: 4, GPUGen: 5, Width: 16, WidthMax: 16}, "PCIe Gen4 of its Gen5: 31.5 GB/s where it could have 63.0"},
// Both, behind a riser.
{PCIeLink{Gen: 3, GPUGen: 4, Width: 4, WidthMax: 16}, "x4 of its x16 lanes and PCIe Gen3 of its Gen4"},
// A driver that says nothing.
{PCIeLink{}, ""},
} {
got := c.link.Problem()
if (c.want == "") != (got == "") || !strings.Contains(got, c.want) {
t.Errorf("%+v: %q, want %q", c.link, got, c.want)
}
}
}
func TestTheLinkIsReadFromNvidiaSMI(t *testing.T) {
links := parsePCIe("0, 3, 16, 16, 3\n1, 4, 8, 16, [N/A]\n")
if links[0] != (PCIeLink{Gen: 3, GPUGen: 3, Width: 16, WidthMax: 16}) || links[1] != (PCIeLink{Gen: 4, Width: 8, WidthMax: 16}) {
t.Errorf("%+v", links)
}
}
func TestTheCardsOwnLanesComeFromTheKernel(t *testing.T) {
// The driver says x8 of x8 for a x16 card in a slot wired x8; the
// device's own max_link_width says 16.
root := t.TempDir()
dev := filepath.Join(root, "0000:3b:00.0")
_ = os.MkdirAll(dev, 0o755)
for f, v := range map[string]string{"max_link_width": "16", "current_link_width": "8", "max_link_speed": "16.0 GT/s PCIe"} {
_ = os.WriteFile(filepath.Join(dev, f), []byte(v+"\n"), 0o644)
}
l := withSysfs(PCIeLink{Gen: 4, Width: 8, WidthMax: 8}, root, "00000000:3B:00.0")
if l.WidthMax != 16 || l.Width != 8 || l.GPUGen != 4 || !strings.Contains(l.Problem(), "x8 of its x16") {
t.Errorf("%+v: %q", l, l.Problem())
}
}
+176
View File
@@ -0,0 +1,176 @@
package discovery
import (
"context"
"io"
"net/http"
"strconv"
"strings"
"time"
)
// GPUProfile is how much of a card's chip the work on it uses. nvidia-smi's
// utilization.gpu cannot say: it is the share of time any kernel ran, and a
// kernel keeping one SM of a hundred busy reads 100%. Measured on a Quadro
// RTX 6000 under decode, it read 92% while the SMs had work 69% of the time
// and issued an instruction on 18% of their cycles.
//
// These come from the card's profiling counters, which only two things read
// without stopping the work: NVML's GPM, on Hopper and later, and DCGM, which
// a site may run as dcgm-exporter. Percentages; -1 is not reported.
type GPUProfile struct {
// SMActive is the share of time the SMs had work, averaged over all of
// them; SMOccupancy how full those SMs were with warps.
SMActive, SMOccupancy float64
// TensorActive is the tensor cores. Decode at batch one hardly uses them
// even when nothing is wrong: a quantised matrix-vector product runs on
// the integer units. Prefill and batches are where they show.
TensorActive float64
// DRAMActive is the memory interface, which decode is bound by.
DRAMActive float64
// Source is "gpm" or "dcgm".
Source string
}
// DefaultDCGMExporter is where dcgm-exporter listens unless told otherwise.
const DefaultDCGMExporter = "http://127.0.0.1:9400/metrics"
// dcgmRetry is how long a dcgm-exporter that did not answer is left alone.
// Most nodes run none, and asking every ten seconds would be asking nobody.
const dcgmRetry = 5 * time.Minute
// gpmSource reads GPM, where this build and the card have it.
type gpmSource interface {
read() map[int]GPUProfile
}
// ProfSampler reads each card's GPUProfile: from GPM where the card has it,
// otherwise from a dcgm-exporter on the node.
type ProfSampler struct {
gpm gpmSource
dcgm string
get func(ctx context.Context, url string) ([]byte, error)
now func() time.Time
dcgmAfter time.Time
}
// NewProfSampler reads this machine. dcgmURL is the dcgm-exporter to ask:
// empty is DefaultDCGMExporter, "off" asks none.
func NewProfSampler(dcgmURL string) *ProfSampler {
switch dcgmURL {
case "":
dcgmURL = DefaultDCGMExporter
case "off":
dcgmURL = ""
}
return &ProfSampler{gpm: newGPM(), dcgm: dcgmURL, get: httpGet, now: time.Now}
}
// Sample is each card's profile by index; a card missing has none. GPM needs
// two samples, so its cards appear from the second call.
func (p *ProfSampler) Sample(ctx context.Context) map[int]GPUProfile {
out := map[int]GPUProfile{}
if p.gpm != nil {
for i, v := range p.gpm.read() {
out[i] = v
}
}
if p.dcgm == "" || p.now().Before(p.dcgmAfter) {
return out
}
body, err := p.get(ctx, p.dcgm)
if err != nil {
p.dcgmAfter = p.now().Add(dcgmRetry)
return out
}
for i, v := range parseDCGM(body) {
if _, ok := out[i]; !ok {
out[i] = v
}
}
return out
}
func httpGet(ctx context.Context, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return nil, io.ErrUnexpectedEOF
}
return io.ReadAll(io.LimitReader(resp.Body, 4<<20))
}
// The dcgm-exporter fields read, as ratios of one. Its default field list
// has them on every card DCGM can profile.
var dcgmFields = map[string]func(*GPUProfile, float64){
"DCGM_FI_PROF_SM_ACTIVE": func(p *GPUProfile, v float64) { p.SMActive = v },
"DCGM_FI_PROF_SM_OCCUPANCY": func(p *GPUProfile, v float64) { p.SMOccupancy = v },
"DCGM_FI_PROF_PIPE_TENSOR_ACTIVE": func(p *GPUProfile, v float64) { p.TensorActive = v },
"DCGM_FI_PROF_DRAM_ACTIVE": func(p *GPUProfile, v float64) { p.DRAMActive = v },
}
// parseDCGM reads dcgm-exporter's text exposition: a line per field and
// card, the card in the gpu label. A card with none of the fields — DCGM
// cannot profile it — is left out. A MIG instance's lines carry a
// GPU_I_ID and are left out too: these are whole cards.
func parseDCGM(body []byte) map[int]GPUProfile {
out := map[int]GPUProfile{}
for _, line := range strings.Split(string(body), "\n") {
if line == "" || line[0] == '#' {
continue
}
open := strings.IndexByte(line, '{')
end := strings.LastIndexByte(line, '}')
if open < 0 || end < open {
continue
}
set, ok := dcgmFields[line[:open]]
if !ok {
continue
}
labels := line[open+1 : end]
if strings.Contains(labels, `GPU_I_ID="`) && !strings.Contains(labels, `GPU_I_ID=""`) {
continue
}
idx, err := strconv.Atoi(labelValue(labels, "gpu"))
if err != nil {
continue
}
val := strings.Fields(line[end+1:])
if len(val) == 0 {
continue
}
v, err := strconv.ParseFloat(val[0], 64)
if err != nil || v < 0 {
continue
}
p, seen := out[idx]
if !seen {
p = GPUProfile{SMActive: -1, SMOccupancy: -1, TensorActive: -1, DRAMActive: -1, Source: "dcgm"}
}
set(&p, 100*v)
out[idx] = p
}
return out
}
// labelValue is one label's value from a Prometheus label set.
func labelValue(labels, name string) string {
for _, kv := range strings.Split(labels, ",") {
k, v, ok := strings.Cut(strings.TrimSpace(kv), "=")
if ok && k == name {
return strings.Trim(v, `"`)
}
}
return ""
}
+107
View File
@@ -0,0 +1,107 @@
package discovery
import (
"context"
"errors"
"testing"
"time"
)
// dcgmBody is dcgm-exporter's output for two cards and a MIG instance:
// card 0 profiled, card 1 a model DCGM cannot profile, which has the device
// fields and none of the PROF ones.
const dcgmBody = `# HELP DCGM_FI_DEV_GPU_UTIL GPU utilization (in %).
# TYPE DCGM_FI_DEV_GPU_UTIL gauge
DCGM_FI_DEV_GPU_UTIL{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 100
DCGM_FI_DEV_GPU_UTIL{gpu="1",UUID="GPU-b",device="nvidia1",modelName="Quadro RTX 6000",Hostname="n1"} 92
# HELP DCGM_FI_PROF_SM_ACTIVE The ratio of cycles an SM has at least 1 warp assigned.
# TYPE DCGM_FI_PROF_SM_ACTIVE gauge
DCGM_FI_PROF_SM_ACTIVE{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 0.007042
DCGM_FI_PROF_SM_ACTIVE{gpu="2",UUID="GPU-c",device="nvidia2",modelName="NVIDIA A100",GPU_I_PROFILE="3g.40gb",GPU_I_ID="1",Hostname="n1"} 0.5
DCGM_FI_PROF_SM_OCCUPANCY{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 0.25
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 0
`
func TestDCGMExporterSaysHowMuchOfEachChipIsInUse(t *testing.T) {
got := parseDCGM([]byte(dcgmBody))
if len(got) != 1 {
t.Fatalf("want card 0 alone (card 1 unprofiled, card 2 a MIG instance): %+v", got)
}
p := got[0]
// The LinkedIn L40S: 100% busy, one SM of 142 at work.
if p.SMActive < 0.70 || p.SMActive > 0.71 || p.SMOccupancy != 25 || p.TensorActive != 0 || p.Source != "dcgm" {
t.Errorf("card 0 %+v", p)
}
// A field the exporter does not send is unknown, not zero.
if p.DRAMActive != -1 {
t.Errorf("DRAM active not sent, read as %v", p.DRAMActive)
}
}
type fakeGPM map[int]GPUProfile
func (f fakeGPM) read() map[int]GPUProfile { return f }
func TestGPMIsPreferredAndDCGMFillsTheCardsItDoesNotCover(t *testing.T) {
calls := 0
p := &ProfSampler{
gpm: fakeGPM{1: {SMActive: 40, Source: "gpm"}},
dcgm: DefaultDCGMExporter,
get: func(context.Context, string) ([]byte, error) {
calls++
return []byte(`DCGM_FI_PROF_SM_ACTIVE{gpu="0"} 0.5` + "\n" + `DCGM_FI_PROF_SM_ACTIVE{gpu="1"} 0.9` + "\n"), nil
},
now: time.Now,
}
got := p.Sample(t.Context())
if got[0].Source != "dcgm" || got[0].SMActive != 50 {
t.Errorf("card 0 %+v", got[0])
}
if got[1].Source != "gpm" || got[1].SMActive != 40 {
t.Errorf("GPM's card was taken from DCGM: %+v", got[1])
}
if calls != 1 {
t.Errorf("%d calls", calls)
}
}
func TestANodeWithoutDCGMIsNotAskedEverySample(t *testing.T) {
now := time.Now()
calls := 0
p := &ProfSampler{dcgm: DefaultDCGMExporter, now: func() time.Time { return now },
get: func(context.Context, string) ([]byte, error) { calls++; return nil, errors.New("connection refused") }}
for range 5 {
if got := p.Sample(t.Context()); len(got) != 0 {
t.Fatalf("%+v", got)
}
now = now.Add(10 * time.Second)
}
if calls != 1 {
t.Errorf("asked %d times in 50 s", calls)
}
now = now.Add(dcgmRetry)
p.Sample(t.Context())
if calls != 2 {
t.Errorf("not asked again after %v", dcgmRetry)
}
if off := NewProfSampler("off"); off.dcgm != "" {
t.Errorf("off still asks %q", off.dcgm)
}
}
func TestEachSampleCarriesTheProfileOfTheCardsThatHaveOne(t *testing.T) {
smi := func(context.Context, string) ([]byte, error) {
return []byte("0, 71, 239, 260, 0x0, 92, 30, 23000, 24576, 1878, 0, 0\n" +
"1, 30, 20, 260, 0x0, 0, 0, 512, 24576, 300, 0, 0\n"), nil
}
now := time.Now()
s := sampler(t.TempDir(), smi, &now).WithProfile(&ProfSampler{
gpm: fakeGPM{0: {SMActive: 69, SMOccupancy: 45, TensorActive: 1, DRAMActive: 33, Source: "gpm"}}, now: time.Now})
got := s.Sample(t.Context()).GPUs
if got[0].Profile == nil || got[0].Profile.SMActive != 69 || got[0].UtilPercent != 92 {
t.Errorf("card 0 %+v", got[0])
}
if got[1].Profile != nil {
t.Errorf("card 1 has a profile nobody read: %+v", got[1].Profile)
}
}
+26
View File
@@ -49,6 +49,9 @@ type GPUSensor struct {
MemoryUsedMB, MemoryTotalMB float64
SMClockMHz float64
ECCUncorrected float64
// Profile is how much of the chip the work uses, where something on the
// node can say: nil on most cards. UtilPercent is not that.
Profile *GPUProfile
}
// sensorEvery bounds how often nvidia-smi is run. Heartbeats come more often
@@ -61,6 +64,8 @@ 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
// prof reads the cards' profiling counters; nil reads none.
prof *ProfSampler
mu sync.Mutex
last Sensors
@@ -74,6 +79,19 @@ func NewSensorSampler() *SensorSampler {
return &SensorSampler{sys: "/sys", smi: runSMI, now: time.Now}
}
// WithProfile has each sample carry the cards' profiles as well.
func (s *SensorSampler) WithProfile(p *ProfSampler) *SensorSampler {
s.prof = p
return s
}
// SampleGPUs reads every card's heat, power and how busy it is now, without
// a sampler's ten seconds of memory: for a screen that asks every few seconds
// and wants what it asked for. Nil on a machine without nvidia-smi or a card.
func SampleGPUs(ctx context.Context) []GPUSensor {
return (&SensorSampler{smi: runSMI}).gpus(ctx)
}
func runSMI(ctx context.Context, fields string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
@@ -94,6 +112,14 @@ func (s *SensorSampler) Sample(ctx context.Context) Sensors {
out.CPUTempC, out.InletTempC = s.hwmon()
out.CPUPowerW = s.rapl(now)
out.GPUs = s.gpus(ctx)
if s.prof != nil {
profs := s.prof.Sample(ctx)
for i := range out.GPUs {
if p, ok := profs[out.GPUs[i].Index]; ok {
out.GPUs[i].Profile = &p
}
}
}
s.last, s.lastAt = out, now
return out
}
+151
View File
@@ -0,0 +1,151 @@
package engines
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"sync/atomic"
)
// ComfyUIConfig locates a ComfyUI installation.
type ComfyUIConfig struct {
// Dir is ComfyUI's directory, where main.py is. Readable by the user
// engines run as.
Dir string `koanf:"dir"`
// Python is the interpreter of its environment; Dir's .venv when unset.
Python string `koanf:"python"`
}
// ComfyUI runs ComfyUI as an engine, behind llmgw's gate (llmgw
// comfyui-gate): ComfyUI asks for no key, so it listens on the loopback and
// the gate, which does, on the replica's port. Its replicas make pictures
// for /v1/images/generations.
type ComfyUI struct {
cfg ComfyUIConfig
installed atomic.Pointer[string]
}
// Name identifies the engine in the catalog.
func (*ComfyUI) Name() string { return "comfyui" }
// HealthPath is the gate's, which answers once ComfyUI does.
func (*ComfyUI) HealthPath() string { return "/health" }
// MetricsPath is empty: ComfyUI publishes no metrics, and the router reads
// none from it.
func (*ComfyUI) MetricsPath() string { return "" }
// ServesHTTP is true: a ComfyUI replica is one process on one node.
func (*ComfyUI) ServesHTTP(ShardSpec) bool { return true }
// UseInstalled points the engine at the environment the agent installed:
// its interpreter, with ComfyUI's code beside its bin, in comfyui/.
func (c *ComfyUI) UseInstalled(python string) { c.installed.Store(&python) }
// Configured reports whether the configuration names a ComfyUI.
func (c *ComfyUI) Configured() bool { return c.cfg.Dir != "" }
func (c *ComfyUI) dir() string {
if p := c.installed.Load(); p != nil {
return filepath.Join(filepath.Dir(filepath.Dir(*p)), "comfyui")
}
return c.cfg.Dir
}
func (c *ComfyUI) python() string {
if p := c.installed.Load(); p != nil {
return *p
}
if c.cfg.Python != "" {
return c.cfg.Python
}
return filepath.Join(c.cfg.Dir, ".venv", "bin", "python")
}
var comfyVersion = regexp.MustCompile(`__version__\s*=\s*"([^"]+)"`)
// Probe reports ComfyUI's version, from the file its build writes it to:
// importing ComfyUI to ask would load torch and look for cards.
func (c *ComfyUI) Probe(context.Context) (string, error) {
dir := c.dir()
if dir == "" {
return "", fmt.Errorf("no ComfyUI directory is configured")
}
if _, err := os.Stat(filepath.Join(dir, "main.py")); err != nil {
return "", fmt.Errorf("%s is not ComfyUI: %w", dir, err)
}
b, err := os.ReadFile(filepath.Join(dir, "comfyui_version.py"))
if err != nil {
return Unknown, nil //nolint:nilerr // installed, of a version it does not say
}
if m := comfyVersion.FindSubmatch(b); m != nil {
return string(m[1]), nil
}
return Unknown, nil
}
// Validate checks the profile: one card, on one node. ComfyUI runs a
// workflow on one device and has no tensor parallelism to spread it by.
func (*ComfyUI) Validate(p Profile, gpus []GPUInfo) error {
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
return fmt.Errorf("profile %q: ComfyUI runs on one GPU of one node", p.Name)
}
return validateCommon(p, gpus)
}
// Commands is the gate, which starts ComfyUI itself.
func (c *ComfyUI) Commands(s ShardSpec) ([]Command, error) {
dir := c.dir()
if dir == "" {
return nil, fmt.Errorf("comfyui: no ComfyUI directory is configured (engines.comfyui.dir)")
}
bin, err := os.Executable()
if err != nil {
return nil, err
}
p := s.Profile
args := []string{"comfyui-gate",
"--listen", net.JoinHostPort(s.Host(), strconv.Itoa(s.HTTPPort)),
"--model", p.ServedModelName,
"--weights", p.ModelPath,
// Relative to where engines run — their user's home — so it is
// theirs to write.
"--state", filepath.Join("comfyui", s.ReplicaID),
"--python", c.python(),
"--comfyui", dir,
"--",
}
args = append(args, p.Args...)
env := baseEnv(s)
if s.APIKey != "" {
env["LLMGW_ENGINE_KEY"] = s.APIKey
}
// A model's custom nodes and the repository a node loads its weights by,
// which only the catalog sets — a profile's env may not name them — and
// so taken before baseEnv drops them. Without the repository the gate
// links no Hugging Face cache, and sf3d's node went to the network for
// its gated repository and was refused with a 401.
for _, k := range []string{comfyNodesEnv, comfyHFRepoEnv, SharedModelsEnv} {
if v := p.Env[k]; v != "" {
env[k] = v
}
}
return []Command{{Name: "comfyui", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
}
// comfyNodesEnv is the custom nodes the catalog gives a ComfyUI model, as
// JSON; catalog.NodesEnv and comfy.NodesEnv by the same name.
const comfyNodesEnv = "LLMGW_COMFYUI_NODES"
// comfyHFRepoEnv is the repository a custom node loads the weights by name
// from; catalog.HFRepoEnv and comfy.HFRepoEnv by the same name.
const comfyHFRepoEnv = "LLMGW_COMFYUI_HF_REPO"
// SharedModelsEnv is a shared replica's members — name, source, and the
// path the agent fetched it to — as JSON; catalog.SharedModelsEnv and
// comfy.SharedModelsEnv by the same name.
const SharedModelsEnv = "LLMGW_COMFYUI_MODELS"
+99
View File
@@ -0,0 +1,99 @@
package engines
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestComfyUIRunsBehindTheGateWithItsKeyOutOfSight(t *testing.T) {
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/comfyui"}}
cmds, err := c.Commands(ShardSpec{
ReplicaID: "r-flux-1", HTTPPort: 8100, BindAddr: "10.0.0.147", APIKey: "sekret", GPUIndices: []int{0},
Profile: Profile{Name: "p", Engine: "comfyui", ModelPath: "/w/flux.safetensors", ServedModelName: "flux",
Args: []string{"--disable-smart-memory"}},
})
if err != nil || len(cmds) != 1 {
t.Fatalf("%v %v", cmds, err)
}
args := strings.Join(cmds[0].Args, " ")
for _, want := range []string{"comfyui-gate", "--listen 10.0.0.147:8100", "--model flux", "--weights /w/flux.safetensors",
"--state comfyui/r-flux-1", "--python /opt/comfyui/.venv/bin/python", "--comfyui /opt/comfyui", "-- --disable-smart-memory"} {
if !strings.Contains(args, want) {
t.Errorf("%q not in %q", want, args)
}
}
if strings.Contains(args, "sekret") {
t.Error("the key is on the command line, where every user can read it")
}
if !slices.Contains(cmds[0].Env, "LLMGW_ENGINE_KEY=sekret") || !slices.Contains(cmds[0].Env, "CUDA_VISIBLE_DEVICES=0") {
t.Error("the key or the card is not in the environment")
}
}
func TestTheGateIsToldTheNodesAndTheRepositoryTheCatalogGives(t *testing.T) {
// The repository was dropped on the way: sf3d's node went to the network
// for its gated repository, with no cache linked, and got a 401.
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/comfyui"}}
cmds, err := c.Commands(ShardSpec{
ReplicaID: "r-sf3d-1", HTTPPort: 8100, BindAddr: "10.0.0.147", GPUIndices: []int{0},
Profile: Profile{Name: "p", Engine: "comfyui", ModelPath: "/w/sf3d/", ServedModelName: "sf3d",
Env: map[string]string{comfyNodesEnv: `[{"repo":"x"}]`, comfyHFRepoEnv: "stabilityai/stable-fast-3d"}},
})
if err != nil || len(cmds) != 1 {
t.Fatalf("%v %v", cmds, err)
}
for _, want := range []string{comfyNodesEnv + `=[{"repo":"x"}]`, comfyHFRepoEnv + "=stabilityai/stable-fast-3d"} {
if !slices.Contains(cmds[0].Env, want) {
t.Errorf("%q not in the gate's environment", want)
}
}
}
func TestComfyUIRunsOnOneCard(t *testing.T) {
c := &ComfyUI{}
if err := c.Validate(Profile{Name: "p", ModelPath: "/w", TP: 2}, h100(2)); err == nil {
t.Error("a two-card ComfyUI profile was accepted")
}
if err := c.Validate(Profile{Name: "p", ModelPath: "/w", TP: 1}, h100(1)); err != nil {
t.Error(err)
}
if _, err := c.Commands(ShardSpec{Profile: Profile{ModelPath: "/w"}}); err == nil {
t.Error("commands without a ComfyUI directory")
}
}
func TestComfyUIIsOfferedByTheVersionItsBuildWrote(t *testing.T) {
dir := t.TempDir()
c := &ComfyUI{cfg: ComfyUIConfig{Dir: dir}}
if _, err := c.Probe(t.Context()); err == nil {
t.Error("a directory without main.py passed for ComfyUI")
}
_ = os.WriteFile(filepath.Join(dir, "main.py"), nil, 0o644)
_ = os.WriteFile(filepath.Join(dir, "comfyui_version.py"), []byte("__version__ = \"0.39.2\"\n"), 0o644)
if v, err := c.Probe(t.Context()); err != nil || v != "0.39.2" {
t.Errorf("%q %v", v, err)
}
if got := NewRegistry(Config{ComfyUI: ComfyUIConfig{Dir: dir}}).ProbeConfigured(t.Context()); got["comfyui"] != "0.39.2" {
t.Errorf("a configured ComfyUI is reported as %v", got)
}
if err := UnsafeArgs("comfyui", []string{"--enable-manager"}); err == nil {
t.Error("a profile may install ComfyUI nodes")
}
}
func TestAnInstalledComfyUIIsFoundBesideItsInterpreter(t *testing.T) {
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/by-hand"}}
c.UseInstalled("/opt/llmgw/engines/comfyui/0.39.2-cu13/bin/python")
cmds, err := c.Commands(ShardSpec{ReplicaID: "r", HTTPPort: 1, Profile: Profile{ModelPath: "/w", ServedModelName: "m"}})
if err != nil {
t.Fatal(err)
}
args := strings.Join(cmds[0].Args, " ")
if !strings.Contains(args, "--python /opt/llmgw/engines/comfyui/0.39.2-cu13/bin/python") ||
!strings.Contains(args, "--comfyui /opt/llmgw/engines/comfyui/0.39.2-cu13/comfyui") {
t.Errorf("the installed ComfyUI is not what runs: %s", args)
}
}
+79
View File
@@ -0,0 +1,79 @@
package engines
import (
"regexp"
"strconv"
"strings"
)
// Compute is where an engine runs its model, as it said while loading it.
type Compute struct {
// Backend is CUDA, Vulkan, Metal, ROCm or CPU.
Backend string
// Devices are those it named: "CUDA0: Quadro RTX 6000 (7.5)".
Devices []string
// Offloaded of Total layers went to the devices; 0 and 0 when it did
// not say.
Offloaded, Total int
// NoGPU is the engine saying it found no device it could use.
NoGPU bool
}
var (
offloadedRE = regexp.MustCompile(`offloaded (\d+)/(\d+) layers to GPU`)
usingRE = regexp.MustCompile(`using device ([A-Za-z]+\d+) \(([^)]*)\)`)
ccRE = regexp.MustCompile(`Device (\d+): ([^,]+), compute capability ([0-9.]+)`)
noDevicesRE = regexp.MustCompile(`found 0 (CUDA|Vulkan|ROCm) devices|no usable GPU found`)
)
// ReadLlamaCompute reads llama.cpp's start-up output for where it put the
// model, reporting false when the output says nothing of it.
func ReadLlamaCompute(lines []string) (Compute, bool) {
var c Compute
known := false
cc := map[string]string{} // device index → compute capability
for _, l := range lines {
if m := ccRE.FindStringSubmatch(l); m != nil {
cc[m[1]] = m[3]
}
}
seen := map[string]bool{}
for _, l := range lines {
if m := offloadedRE.FindStringSubmatch(l); m != nil {
c.Offloaded, _ = strconv.Atoi(m[1])
c.Total, _ = strconv.Atoi(m[2])
known = true
}
if m := usingRE.FindStringSubmatch(l); m != nil && !seen[m[1]] {
seen[m[1]] = true
d := m[1] + ": " + strings.TrimSpace(m[2])
idx := strings.TrimLeft(m[1], "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
if v, ok := cc[idx]; ok && strings.HasPrefix(m[1], "CUDA") {
d += " (" + v + ")"
}
c.Devices = append(c.Devices, d)
if c.Backend == "" {
c.Backend = strings.TrimRight(m[1], "0123456789")
}
known = true
}
if noDevicesRE.MatchString(l) {
c.NoGPU, known = true, true
}
}
switch {
case c.OnTheCPU():
c.Backend = "CPU"
case c.Backend == "" && c.Offloaded > 0:
c.Backend = "GPU"
case c.Backend == "":
c.Backend = "CPU"
}
return c, known
}
// OnTheCPU is whether a model the engine was given GPUs for is computed on
// the CPU, by its own account: no device found, or no layer offloaded.
func (c Compute) OnTheCPU() bool {
return c.NoGPU || (c.Total > 0 && c.Offloaded == 0)
}
+32
View File
@@ -0,0 +1,32 @@
package engines
import (
"strings"
"testing"
)
func TestLlamaCppSaysWhereItPutTheModel(t *testing.T) {
gpu := strings.Split(`ggml_cuda_init: found 1 CUDA devices:
Device 0: Quadro RTX 6000, compute capability 7.5, VMM: yes
load_backend: loaded CUDA backend from /opt/llmgw/engines/llamacpp/b10991/libggml-cuda.so
llama_model_load_from_file_impl: using device CUDA0 (Quadro RTX 6000) - 23800 MiB free
load_tensors: offloading 48 repeating layers to GPU
load_tensors: offloaded 49/49 layers to GPU`, "\n")
c, ok := ReadLlamaCompute(gpu)
if !ok || c.Backend != "CUDA" || c.Offloaded != 49 || c.Total != 49 || c.OnTheCPU() ||
len(c.Devices) != 1 || c.Devices[0] != "CUDA0: Quadro RTX 6000 (7.5)" {
t.Errorf("on the GPU: %+v %v", c, ok)
}
cpu := strings.Split(`warning: no usable GPU found, --gpu-layers option will be ignored
warning: one possible reason is that llama.cpp was compiled without GPU support
load_tensors: offloaded 0/49 layers to GPU`, "\n")
c, ok = ReadLlamaCompute(cpu)
if !ok || c.Backend != "CPU" || !c.OnTheCPU() {
t.Errorf("fell back to the CPU: %+v %v", c, ok)
}
if _, ok := ReadLlamaCompute([]string{"main: server is listening on http://127.0.0.1:20001"}); ok {
t.Error("output that says nothing was read as saying something")
}
}
+23
View File
@@ -47,6 +47,13 @@ type Profile struct {
// KVEvents has the engine publish its KV cache events to the agent,
// for the router's precise index. vLLM only.
KVEvents bool `json:"kv_events,omitempty"`
// AllowPickle lets the node fetch weights in a pickle format, which run
// code when loaded; the catalog's allow_pickle.
AllowPickle bool `json:"allow_pickle,omitempty"`
// ModelSHA256 is the sha256 the registry published for the file
// ModelPath names; the node refuses a copy that is not it. Empty when
// nothing is known to check against.
ModelSHA256 string `json:"model_sha256,omitempty"`
}
// ShardSpec is one shard of one replica on this node.
@@ -114,6 +121,10 @@ type ShardSpec struct {
// 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
// Compute is where the engine said it put the model as it became ready,
// kept with the shard's state so an agent restarted later still knows.
Compute *Compute `json:",omitempty"`
}
// Host is where the engine listens.
@@ -210,6 +221,9 @@ func NewRegistry(cfg Config) *Registry {
r.Add(&LlamaCpp{cfg: cfg.LlamaCpp})
r.Add(&TRTLLM{cfg: cfg.TRTLLM})
r.Add(&Sim{cfg: cfg.Sim})
r.Add(&ComfyUI{cfg: cfg.ComfyUI})
r.Add(&Strata{cfg: cfg.Strata})
r.Add(&FreeToken{cfg: cfg.FreeToken})
return r
}
@@ -258,6 +272,15 @@ type Config struct {
// offers it only when its interpreter is named here.
TRTLLM TRTLLMConfig `koanf:"trtllm"`
Sim SimConfig `koanf:"sim"`
// ComfyUI makes pictures; offered by a node whose ComfyUI is named here.
ComfyUI ComfyUIConfig `koanf:"comfyui"`
// Strata serves a few mixture-of-experts models from RAM and one card;
// offered by a node whose Strata is named here, or that installed it.
Strata StrataConfig `koanf:"strata"`
// FreeToken serves mixture-of-experts models larger than a card from
// RAM and one card of 8.0 or newer; installed by the agent where the
// cards allow it, or named here.
FreeToken FreeTokenConfig `koanf:"freetoken"`
}
// --- shared helpers ---
+2 -2
View File
@@ -477,14 +477,14 @@ func TestValidateRequiresModelPath(t *testing.T) {
func TestRegistry(t *testing.T) {
r := NewRegistry(Config{})
if got := r.Names(); !slices.Equal(got, []string{"llamacpp", "sglang", "sim", "trtllm", "vllm"}) {
if got := r.Names(); !slices.Equal(got, []string{"comfyui", "freetoken", "llamacpp", "sglang", "sim", "strata", "trtllm", "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(), "llamacpp, sglang, sim, trtllm, vllm") {
if err == nil || !strings.Contains(err.Error(), "llamacpp, sglang, sim, strata, trtllm, vllm") {
t.Errorf("an unknown engine should list what is available: %v", err)
}
}
+137
View File
@@ -0,0 +1,137 @@
package engines
import (
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"sync/atomic"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
// FreeTokenConfig locates a FreeToken installation.
type FreeTokenConfig struct {
// Binary is its ft command, in the environment it was installed into.
// The agent installs one itself on a node whose GPUs it runs on; this
// is for one installed by hand.
Binary string `koanf:"binary"`
}
// freetokenToolkit finds the CUDA 13 toolkit; a variable for tests.
var freetokenToolkit = func() string { return discovery.CUDAToolkit(13) }
// FreeToken runs FreeToken (github.com/FlashML-org/FreeToken): a
// mixture-of-experts engine that keeps a model's experts in host RAM, the
// ones used most in VRAM, and streams the rest over PCIe, so a model several
// times a card's size serves from one card. It reads Hugging Face
// safetensors checkpoints as they are published (FP8, NVFP4, MXFP4, BF16)
// and serves the OpenAI and Anthropic APIs. One card of compute capability
// 8.0 or newer; it compiles some of its kernels on first start, with the
// CUDA 13 toolkit.
type FreeToken struct {
cfg FreeTokenConfig
installed atomic.Pointer[string]
}
// Name identifies the engine in the catalog.
func (*FreeToken) Name() string { return "freetoken" }
// HealthPath answers before the model is loaded too, with its progress in
// the body; the supervisor waits for the status code, so it counts as ready
// only once FreeToken's own startup — loading, warm-up, CUDA graphs — is
// over and the API server is accepting.
func (*FreeToken) HealthPath() string { return "/health" }
// MetricsPath is empty: FreeToken serves its numbers as JSON (/v1/stats),
// not in Prometheus' text, so the router counts its requests itself.
func (*FreeToken) MetricsPath() string { return "" }
// ServesHTTP is true: a FreeToken replica is one process on one node.
func (*FreeToken) ServesHTTP(ShardSpec) bool { return true }
// UseInstalled points the engine at the ft the agent installed.
func (f *FreeToken) UseInstalled(path string) { f.installed.Store(&path) }
// Configured reports whether the configuration names a FreeToken.
func (f *FreeToken) Configured() bool { return f.cfg.Binary != "" }
func (f *FreeToken) binary() string {
if p := f.installed.Load(); p != nil {
return *p
}
return f.cfg.Binary
}
// HelpCommand is ft serve's own help.
func (f *FreeToken) HelpCommand(Command) (string, []string) {
return f.binary(), []string{"serve", "--help"}
}
// Probe reports the FreeToken a configuration names, by what it says its
// version is.
func (f *FreeToken) Probe(ctx context.Context) (string, error) {
bin := f.binary()
if bin == "" {
return "", fmt.Errorf("no FreeToken is configured")
}
out, err := exec.CommandContext(ctx, bin, "--version").Output() //nolint:gosec // the configured engine
if err != nil {
return "", fmt.Errorf("%s does not run: %w", bin, err)
}
// "freetoken version 0.2.1"
if fs := strings.Fields(string(out)); len(fs) > 0 {
return fs[len(fs)-1], nil
}
return Unknown, nil
}
// Validate checks the profile: one card of 8.0 or newer, on one node.
// FreeToken has no tensor parallel; it picks one device.
func (*FreeToken) Validate(p Profile, gpus []GPUInfo) error {
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
return fmt.Errorf("profile %q: freetoken runs on one GPU of one node", p.Name)
}
if err := requireComputeCapability(p, gpus, 8.0, "freetoken"); err != nil {
return err
}
return validateCommon(p, gpus)
}
// Commands is ft serve. The card is the shard's, by CUDA_VISIBLE_DEVICES,
// so it is device 0 to FreeToken; its key goes in the environment, where it
// reads it from, rather than on a command line every user can read.
func (f *FreeToken) Commands(sh ShardSpec) ([]Command, error) {
bin := f.binary()
if bin == "" {
return nil, fmt.Errorf("freetoken: no FreeToken is installed or configured (engines.freetoken.binary)")
}
toolkit := freetokenToolkit()
if toolkit == "" {
return nil, fmt.Errorf("freetoken compiles its kernels on first start and this node has no CUDA 13 toolkit " +
"(nvcc in CUDA_HOME, /usr/local/cuda or /usr/local/cuda-13.*)")
}
p := sh.Profile
args := []string{"serve",
"--model", p.ModelPath,
"--host", sh.Host(),
"--port", strconv.Itoa(sh.HTTPPort),
"--gpu", "0",
}
if p.ServedModelName != "" {
args = append(args, "--served-model-name", p.ServedModelName)
}
args = append(args, p.Args...)
env := baseEnv(sh)
env["CUDA_HOME"] = toolkit
// nvcc for the kernels it compiles, and the environment's own bin
// first, where ninja is.
env["PATH"] = strings.Join([]string{filepath.Dir(bin), filepath.Join(toolkit, "bin"), os.Getenv("PATH")}, ":")
if sh.APIKey != "" {
env["FREETOKEN_API_KEY"] = sh.APIKey
}
return []Command{{Name: "freetoken", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
}
+112
View File
@@ -0,0 +1,112 @@
package engines
import (
"slices"
"strings"
"testing"
)
func withToolkit(t *testing.T, dir string) {
t.Helper()
old := freetokenToolkit
freetokenToolkit = func() string { return dir }
t.Cleanup(func() { freetokenToolkit = old })
}
func TestFreeTokenServesOnTheShardsCardWithTheKeyInTheEnvironment(t *testing.T) {
withToolkit(t, "/usr/local/cuda-13.4")
f := &FreeToken{}
f.UseInstalled("/opt/llmgw/engines/freetoken/0.2.1-cu13/bin/ft")
spec := ShardSpec{
ReplicaID: "r1", HTTPPort: 18091, BindAddr: "10.0.0.160", GPUIndices: []int{3},
APIKey: "secret-key",
Profile: Profile{Name: "p", Engine: "freetoken", TP: 1, ModelPath: "/var/lib/llmgw-weights/glm-5.2-nvfp4",
ServedModelName: "glm-5.2", Args: []string{"--max-running-requests", "8"}},
}
cmds, err := f.Commands(spec)
if err != nil {
t.Fatal(err)
}
c := cmds[0]
if c.Path != "/opt/llmgw/engines/freetoken/0.2.1-cu13/bin/ft" {
t.Errorf("path %s", c.Path)
}
args := strings.Join(c.Args, " ")
for _, want := range []string{
"serve --model /var/lib/llmgw-weights/glm-5.2-nvfp4", "--host 10.0.0.160", "--port 18091",
// The shard's card is device 0 once CUDA_VISIBLE_DEVICES pins it.
"--gpu 0", "--served-model-name glm-5.2", "--max-running-requests 8",
} {
if !strings.Contains(args, want) {
t.Errorf("args lack %q: %s", want, args)
}
}
if strings.Contains(args, "secret-key") {
t.Error("the key is on the command line, which every user on the node can read")
}
for _, want := range []string{"FREETOKEN_API_KEY=secret-key", "CUDA_VISIBLE_DEVICES=3", "CUDA_HOME=/usr/local/cuda-13.4"} {
if !slices.Contains(c.Env, want) {
t.Errorf("env lacks %s", want)
}
}
var path string
for _, e := range c.Env {
if p, ok := strings.CutPrefix(e, "PATH="); ok {
path = p
}
}
if !strings.HasPrefix(path, "/opt/llmgw/engines/freetoken/0.2.1-cu13/bin:/usr/local/cuda-13.4/bin:") {
t.Errorf("nvcc and the environment's tools are not first on PATH: %s", path)
}
}
func TestFreeTokenWithoutTheToolkitSaysSoInsteadOfFailingOnItsFirstModel(t *testing.T) {
withToolkit(t, "")
f := &FreeToken{cfg: FreeTokenConfig{Binary: "/x/bin/ft"}}
_, err := f.Commands(ShardSpec{HTTPPort: 1, Profile: Profile{Name: "p", ModelPath: "/m"}})
if err == nil || !strings.Contains(err.Error(), "CUDA 13 toolkit") {
t.Errorf("got %v", err)
}
}
func TestFreeTokenRunsOnOneAmpereOrNewerCard(t *testing.T) {
f := &FreeToken{}
p := Profile{Name: "p", Engine: "freetoken", TP: 1, ModelPath: "/m"}
if err := f.Validate(p, []GPUInfo{{Name: "NVIDIA RTX A6000", ComputeCapability: "8.6", MemoryTotalMB: 49140}}); err != nil {
t.Errorf("an Ampere card was refused: %v", err)
}
// Tried on cuda01: no kernel image for the card, then an illegal memory
// access in fp16.
if err := f.Validate(p, []GPUInfo{{Name: "Quadro RTX 6000", ComputeCapability: "7.5", MemoryTotalMB: 24576}}); err == nil {
t.Error("a Turing card was accepted")
}
p.TP = 2
if err := f.Validate(p, h100(2)); err == nil {
t.Error("a two-card profile was accepted; FreeToken has no tensor parallel")
}
}
func TestFreeTokenFlagsThatReadFilesOrMoveWhatTheAgentSetsAreRefused(t *testing.T) {
for _, a := range []string{
"--gpu", "--served-model-name=x", "--mmproj", "--allowed-local-media-path",
// argparse takes a beginning for the whole flag.
"--allowed-local", "--api-key", "--port", "--hf-overrides",
} {
if UnsafeArgs("freetoken", []string{a, "v"}) == nil {
t.Errorf("%s was allowed", a)
}
}
for _, a := range []string{"--max-running-requests", "--moe-strategy", "--dtype", "--max-seq-len-override", "--moe-cache-auto"} {
if err := UnsafeArgs("freetoken", []string{a, "1"}); err != nil {
t.Errorf("%s was refused: %v", a, err)
}
}
for k, refuse := range map[string]bool{
"FREETOKEN_PIN_BUDGET_GB": false, "FREETOKEN_API_KEY": true, "FREETOKEN_API_LOG_DIR": true,
"FREETOKEN_MMPROJ": true, "FREETOKEN_HOST": true,
} {
if got, why := UnsafeEnv(k); got != refuse {
t.Errorf("%s: refused %v (%s), want %v", k, got, why, refuse)
}
}
}
+3
View File
@@ -277,6 +277,9 @@ func (l *LlamaCpp) bin() string {
return "llama-server"
}
// Binary is the llama-server this engine runs.
func (l *LlamaCpp) Binary() string { return l.bin() }
// FallsBackToCPU says that llama.cpp keeps running when its GPU backend does
// not load: --n-gpu-layers asks for layers on a GPU it then has none of, and
// the server comes up and answers on the CPU, at a tenth of the speed, with no
+58
View File
@@ -0,0 +1,58 @@
package engines
import (
"slices"
"testing"
)
func TestAModelDownloadedByTheEngineIsReadAsSafetensorsOnly(t *testing.T) {
for path, want := range map[string]bool{
"Qwen/Qwen2.5-7B-Instruct": true,
"/var/lib/llmgw/weights/abc/": false, "./local": false,
"http://m/q/v/": false, "q.gguf": false,
} {
got := slices.Contains(safetensorsOnly(Profile{ModelPath: path}), "safetensors")
if got != want {
t.Errorf("%q: safetensors only = %v, want %v", path, got, want)
}
}
if len(safetensorsOnly(Profile{ModelPath: "Qwen/Qwen2.5-7B-Instruct", AllowPickle: true})) != 0 {
t.Error("allow_pickle still forced safetensors")
}
}
func TestAForbiddenFlagIsKnownHoweverItIsSpelled(t *testing.T) {
for engine, args := range map[string][]string{
"vllm": {"--trust_remote_code"},
"sglang": {"--load-format", "pt"},
} {
if UnsafeArgs(engine, args) == nil {
t.Errorf("%s %v was let through", engine, args)
}
}
for _, a := range [][]string{{"--load_format", "pt"}, {"--trust-remote"}, {"--load-form=pt"}} {
if UnsafeArgs("vllm", a) == nil {
t.Errorf("vllm %v was let through", a)
}
}
// What is not one of them, nor the start of one, still passes.
for _, a := range [][]string{{"--max-model-len", "8192"}, {"--tokenizer-mode", "mistral"}, {"--gpu-memory-utilization=0.9"}} {
if err := UnsafeArgs("vllm", a); err != nil {
t.Errorf("vllm %v was refused: %v", a, err)
}
}
// Short flags are matched as written; llama.cpp takes no abbreviations,
// so its --chat-template is not taken for --chat-template-file.
for _, a := range [][]string{{"-m", "x"}, {"-hf", "o/r"}, {"-mu", "http://x"}} {
if UnsafeArgs("llamacpp", a) == nil {
t.Errorf("llamacpp %v was let through", a)
}
}
if err := UnsafeArgs("llamacpp", []string{"--chat-template", "chatml"}); err != nil {
t.Errorf("llama.cpp's own --chat-template was refused: %v", err)
}
// ComfyUI's argparse reads --li as --listen.
if UnsafeArgs("comfyui", []string{"--li", "0.0.0.0"}) == nil {
t.Error("comfyui --li was let through")
}
}
+1
View File
@@ -102,6 +102,7 @@ func (g *SGLang) serverCommand(s ShardSpec) (Command, error) {
if p.ServedModelName != "" {
args = append(args, "--served-model-name", p.ServedModelName)
}
args = append(args, safetensorsOnly(p)...)
if s.NNodes > 1 {
args = append(args,
"--nnodes", strconv.Itoa(s.NNodes),
+184
View File
@@ -0,0 +1,184 @@
package engines
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"sync/atomic"
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
)
// StrataConfig locates a Strata installation.
type StrataConfig struct {
// Dir is Strata's source directory, where setup.py and serve/ are.
Dir string `koanf:"dir"`
// Python is the interpreter of its environment; Dir's .venv when unset.
Python string `koanf:"python"`
// Engine is the directory of its prebuilt engine, where the strata
// binary and the CUDA libraries it ships are; Dir's engine/ when unset.
Engine string `koanf:"engine"`
}
// Strata runs Strata (github.com/Niko1221/Strata): a mixture-of-experts
// engine that keeps a model's experts in RAM and the ones used most in VRAM,
// so a 125B model serves from one 24 GB card. It runs a few model families
// only, each prepared from its GGUF files by Strata's own setup, so the agent
// starts llmgw strata-serve, which prepares the model and then becomes
// Strata's server.
type Strata struct {
cfg StrataConfig
installed atomic.Pointer[string]
}
// Name identifies the engine in the catalog.
func (*Strata) Name() string { return "strata" }
// HealthPath answers 200 only once the model is loaded.
func (*Strata) HealthPath() string { return "/health" }
// MetricsPath is Strata's Prometheus text, in vLLM's names; its /metrics is
// JSON for its own monitor unless asked for this format.
func (*Strata) MetricsPath() string { return "/metrics?format=prometheus" }
// ServesHTTP is true: a Strata replica is one process on one node.
func (*Strata) ServesHTTP(ShardSpec) bool { return true }
// UseInstalled points the engine at the environment the agent installed:
// its interpreter, with Strata's code beside its bin in strata/ and the
// engine in strata-engine/.
func (s *Strata) UseInstalled(python string) { s.installed.Store(&python) }
// Configured reports whether the configuration names a Strata.
func (s *Strata) Configured() bool { return s.cfg.Dir != "" }
// StrataSourceDir and StrataEngineDir are where an installed environment
// keeps Strata's code and its prebuilt engine, beside bin/.
const (
StrataSourceDir = "strata"
StrataEngineDir = "strata-engine"
)
func (s *Strata) env() string {
if p := s.installed.Load(); p != nil {
return filepath.Dir(filepath.Dir(*p))
}
return ""
}
func (s *Strata) dir() string {
if e := s.env(); e != "" {
return filepath.Join(e, StrataSourceDir)
}
return s.cfg.Dir
}
func (s *Strata) engineDir() string {
if e := s.env(); e != "" {
return filepath.Join(e, StrataEngineDir)
}
if s.cfg.Engine != "" {
return s.cfg.Engine
}
return filepath.Join(s.cfg.Dir, "engine")
}
func (s *Strata) python() string {
if p := s.installed.Load(); p != nil {
return *p
}
if s.cfg.Python != "" {
return s.cfg.Python
}
return filepath.Join(s.cfg.Dir, ".venv", "bin", "python")
}
var strataVersion = regexp.MustCompile(`PRODUCT_VERSION\s*=\s*"([^"]+)"`)
// Probe reports the Strata a configuration names, by the version its source
// states: a directory with its setup and server, an interpreter, and the
// engine binary. Without it a Strata named by hand was never reported, and
// so never placed on.
func (s *Strata) Probe(context.Context) (string, error) {
dir := s.dir()
if dir == "" {
return "", fmt.Errorf("no Strata directory is configured")
}
for _, f := range []string{filepath.Join(dir, "setup.py"), filepath.Join(dir, "serve", "server.py"),
s.python(), filepath.Join(s.engineDir(), "strata")} {
if _, err := os.Stat(f); err != nil {
return "", fmt.Errorf("%s is not a whole Strata: %w", dir, err)
}
}
b, err := os.ReadFile(filepath.Join(dir, "strata_cli", "__init__.py"))
if err != nil {
return Unknown, nil //nolint:nilerr // whole, of a version it does not say
}
if m := strataVersion.FindSubmatch(b); m != nil {
return string(m[1]), nil
}
return Unknown, nil
}
// Validate checks the profile: one card, on one node. Strata splits a model
// over a node's cards by layer, but that is not driven from here yet.
func (*Strata) Validate(p Profile, gpus []GPUInfo) error {
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
return fmt.Errorf("profile %q: strata runs on one GPU of one node", p.Name)
}
// Pascal and Volta run its CUDA 12 build, which the install picks for
// them.
if err := requireComputeCapability(p, gpus, 6.0, "strata"); err != nil {
return err
}
// A family whose engine needs more: said now, rather than by Strata's
// setup after the replica was placed here.
if fam, ok := strata.FamilyNamed(strata.ArgValue(p.Args, "--family")); ok && fam.MinCC > 0 {
if err := requireComputeCapability(p, gpus, fam.MinCC, "strata's "+fam.Title+" engine"); err != nil {
return err
}
}
return validateCommon(p, gpus)
}
// Commands is llmgw strata-serve, which prepares the model with Strata's
// setup and then execs Strata's server.
func (s *Strata) Commands(sh ShardSpec) ([]Command, error) {
dir := s.dir()
if dir == "" {
return nil, fmt.Errorf("strata: no Strata directory is configured (engines.strata.dir)")
}
bin, err := os.Executable()
if err != nil {
return nil, err
}
p := sh.Profile
args := []string{"strata-serve",
"--listen", net.JoinHostPort(sh.Host(), strconv.Itoa(sh.HTTPPort)),
"--model", p.ServedModelName,
"--weights", p.ModelPath,
// Relative to where engines run — their user's home — so they are
// theirs to write. The data is shared by the node's replicas: a
// prepared model and its draft layer are gigabytes, the same for
// every replica of it.
"--state", filepath.Join("strata", sh.ReplicaID),
"--data", filepath.Join("strata", "data"),
"--python", s.python(),
"--strata", dir,
"--engine", s.engineDir(),
"--",
}
args = append(args, p.Args...)
env := baseEnv(sh)
if sh.APIKey != "" {
// Strata's server reads its key from here, and strata-serve passes
// it on in the environment: not on a command line every user on the
// node can read.
env["STRATA_API_KEY"] = sh.APIKey
}
return []Command{{Name: "strata", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
}
+113
View File
@@ -0,0 +1,113 @@
package engines
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestStrataStartsStrataServeWithTheKeyInTheEnvironment(t *testing.T) {
s := &Strata{}
s.UseInstalled("/opt/llmgw/engines/strata/0.2.7-cuda13/bin/python")
spec := ShardSpec{
ReplicaID: "r1", HTTPPort: 18090, BindAddr: "10.0.0.147", GPUIndices: []int{0},
APIKey: "secret-key",
Profile: Profile{Name: "p", Engine: "strata", TP: 1, ModelPath: "/var/lib/llmgw/models/q38",
ServedModelName: "qwen3.8-flash-next", Args: []string{"--family", "qwen", "--size", "IQ2_XS"}},
}
cmds, err := s.Commands(spec)
if err != nil {
t.Fatal(err)
}
c := cmds[0]
args := strings.Join(c.Args, " ")
env := "/opt/llmgw/engines/strata/0.2.7-cuda13"
for _, want := range []string{
"strata-serve", "--listen 10.0.0.147:18090", "--model qwen3.8-flash-next",
"--weights /var/lib/llmgw/models/q38", "--state " + filepath.Join("strata", "r1"),
"--python " + env + "/bin/python", "--strata " + env + "/strata", "--engine " + env + "/strata-engine",
"-- --family qwen --size IQ2_XS",
} {
if !strings.Contains(args, want) {
t.Errorf("args lack %q: %s", want, args)
}
}
if strings.Contains(args, "secret-key") {
t.Error("the key is on the command line, which every user on the node can read")
}
if !slices.Contains(c.Env, "STRATA_API_KEY=secret-key") {
t.Error("the key is not in the environment, where Strata's server reads it")
}
if !slices.Contains(c.Env, "CUDA_VISIBLE_DEVICES=0") {
t.Error("the shard is not pinned to its GPU")
}
}
func TestStrataRunsOnOneCard(t *testing.T) {
s := &Strata{}
p := Profile{Name: "p", Engine: "strata", TP: 1, GPUsPerNode: 2, ModelPath: "/m"}
if err := s.Validate(p, nil); err == nil {
t.Error("a two-card profile was accepted")
}
p.GPUsPerNode = 1
if err := s.Validate(p, []GPUInfo{{Name: "Quadro RTX 6000", ComputeCapability: "7.5"}}); err != nil {
t.Errorf("one Turing card was refused: %v", err)
}
}
func TestStrataSetupFlagsThatMoveFilesAreRefused(t *testing.T) {
for _, bad := range []string{"--gguf-dir", "--gguf", "--data-dir=/etc", "--model", "--mod", "--feed", "--host", "--gpus"} {
if err := UnsafeArgs("strata", []string{bad, "x"}); err == nil {
t.Errorf("%s was accepted", bad)
}
}
if err := UnsafeArgs("strata", []string{"--family", "qwen", "--size", "IQ2_XS", "--context", "32768",
"--parallel", "2", "--vram-reserve-mib", "2048", "--vision", "no"}); err != nil {
t.Errorf("an ordinary profile was refused: %v", err)
}
}
func TestAStrataNamedByHandIsReportedWithItsVersion(t *testing.T) {
dir := t.TempDir()
for _, f := range []string{"setup.py", "serve/server.py", ".venv/bin/python", "engine/strata"} {
p := filepath.Join(dir, f)
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(p, nil, 0o755); err != nil {
t.Fatal(err)
}
}
s := &Strata{cfg: StrataConfig{Dir: dir}}
if v, err := s.Probe(t.Context()); err != nil || v != Unknown {
t.Fatalf("%q %v", v, err)
}
_ = os.MkdirAll(filepath.Join(dir, "strata_cli"), 0o755)
_ = os.WriteFile(filepath.Join(dir, "strata_cli", "__init__.py"), []byte("PRODUCT_VERSION = \"0.2.7\"\n"), 0o644)
if v, _ := s.Probe(t.Context()); v != "0.2.7" {
t.Errorf("version %q", v)
}
r := NewRegistry(Config{Strata: StrataConfig{Dir: dir}})
if got := r.ProbeConfigured(t.Context()); got["strata"] != "0.2.7" {
t.Errorf("not reported: %v", got)
}
_ = os.Remove(filepath.Join(dir, "engine", "strata"))
if _, err := s.Probe(t.Context()); err == nil {
t.Error("reported without its engine")
}
}
func TestStrataRefusesDeepSeekOnACardItsEngineWasNotBuiltFor(t *testing.T) {
s := &Strata{}
p := Profile{Name: "p", Engine: "strata", ModelPath: "/m", TP: 1, GPUsPerNode: 1, Args: []string{"--family", "deepseek41", "--size", "IST-DAS-1.75bpw"}}
gpus := []GPUInfo{{Index: 0, Name: "Quadro RTX 6000", ComputeCapability: "7.5"}}
if err := s.Validate(p, gpus); err == nil || !strings.Contains(err.Error(), "8.6") {
t.Errorf("DeepSeek on 7.5: %v", err)
}
p.Args = []string{"--family", "qwen", "--size", "IQ2_XS"}
if err := s.Validate(p, gpus); err != nil {
t.Errorf("Qwen on 7.5: %v", err)
}
}
+117 -3
View File
@@ -2,6 +2,7 @@ package engines
import (
"fmt"
"os"
"strings"
)
@@ -24,7 +25,7 @@ var envFamilies = []string{
"CUDA_", "NCCL_", "NVIDIA_", "NVTE_", "GLOO_", "UCX_", "TORCH_", "PYTORCH_",
"VLLM_", "SGLANG_", "GGML_", "LLAMA_", "OMP_", "MKL_", "OPENBLAS_", "KMP_",
"HF_", "TRANSFORMERS_", "TOKENIZERS_", "TRITON_", "FLASHINFER_",
"TRTLLM_", "TLLM_",
"TRTLLM_", "TLLM_", "FREETOKEN_",
}
// envExact are single variables that are tuning too.
@@ -32,6 +33,14 @@ var envExact = map[string]bool{
"PYTHONUNBUFFERED": true, "PYTHONHASHSEED": true, "TZ": true,
}
// envNeverExact are variables in an allowed family that no profile may set,
// whose names the patterns below do not catch: what the agent sets itself,
// and FreeToken's way of loading a projector file.
var envNeverExact = map[string]string{
"FREETOKEN_API_KEY": "is set by the agent", "FREETOKEN_HOST": "is set by the agent",
"FREETOKEN_MMPROJ": "loads a file",
}
// envNever are names in an allowed family that load code or move where the
// engine reads and writes.
var envNever = []string{
@@ -58,6 +67,9 @@ func UnsafeEnv(key string) (bool, string) {
return true, "only the variables engines are tuned with are allowed (" +
strings.Join(envFamilies, "*, ") + "*)"
}
if why, ok := envNeverExact[k]; ok {
return true, "it " + why
}
for _, n := range envNever {
if strings.Contains(k, n) && !envExact[k] {
return true, "it names a path, a plugin, a library or an address, which is a way to make the engine " +
@@ -101,6 +113,13 @@ var flagsNever = map[string]map[string]string{
"--hf-token": "is a credential", "--alias": "is set by the agent",
"--model-draft": "loads a file", "-md": "loads a file",
"--prompt-cache": "writes a file", "--logdir": "writes files",
// The short spellings of the same.
"-mm": "loads a file; the projector is fetched with the model", "--mmproj-url": "downloads a file",
"-mmu": "downloads a file", "-hfr": "downloads a model", "-hff": "downloads a model",
"--hf-repo-draft": "downloads a model", "-hfd": "downloads a model", "-hfrd": "downloads a model",
"--hf-repo-v": "downloads a model", "-hfv": "downloads a model", "--hf-file-v": "downloads a model",
"-hffv": "downloads a model", "-hft": "is a credential", "-jf": "reads a file", "-a": "is set by the agent",
"--model-vocoder": "loads a file", "-mv": "loads a file",
},
"vllm": {
"--download-dir": "writes files", "--tokenizer": "reads another repository or path",
@@ -118,6 +137,46 @@ var flagsNever = map[string]map[string]string{
"--file-storage-path": "writes files", "--log-requests": "logs prompts",
"--model-path": "is the model's source, set by the agent", "--served-model-name": "is set by the agent",
"--lora-paths": "loads files", "--download-dir": "writes files",
"--load-format": "changes how weights are loaded, some ways running code",
},
// ComfyUI's main.py, behind the gate that sets where it listens and
// where its files are.
"comfyui": {
"--listen": "is set by the agent", "--base-directory": "is set by the agent",
"--extra-model-paths-config": "is set by the agent",
"--output-directory": "writes files", "--temp-directory": "writes files",
"--input-directory": "reads files", "--user-directory": "writes files",
"--database-url": "writes a database elsewhere", "--front-end-root": "serves a directory",
"--enable-cors-header": "lets other sites' pages call it",
"--enable-manager": "installs nodes, which run Python",
"--tls-keyfile": "reads a file", "--tls-certfile": "reads a file",
"--comfy-api-base": "sends work to another service",
},
// Strata's setup.py, which a profile's arguments go to: llmgw
// strata-serve sets where the model is, where its files go and where
// the server listens, and the engine is the one the agent installed.
// Its own --model, the size, is spelt --size in a profile, since
// --model is the source everywhere else.
"strata": {
"--gguf-dir": "is the model's source, set by the agent", "--models-dir": "is set by the agent",
"--data-dir": "is set by the agent", "--move-data": "moves files", "--set-default-data-dir": "writes settings",
"--feed": "downloads an engine", "--prebuilt": "loads an engine", "--build": "compiles an engine",
"--update": "downloads an engine", "--rollback-engine": "changes the engine",
"--dsv41-package": "loads a file", "--inspect": "reads another repository or path",
"--source": "downloads from elsewhere", "--setup": "is not for a server",
"--browser": "opens a browser", "--gpu": "is set by the agent", "--gpus": "is set by the agent",
"--gpu-roles": "is set by the agent", "--backend": "is set by the agent",
"--experimental-speed-projection": "changes what the model refuses",
},
// ft serve: where it listens, its key, its model and its card are the
// agent's, and what reads files or fetches is refused.
"freetoken": {
"--gpu": "is set by the agent", "--served-model-name": "is set by the agent",
"--mmproj": "loads a file", "--mm-encoder-weights": "loads a file",
"--allowed-local-media-path": "serves local files to requests",
"--allowed-media-domains": "lets requests have the engine fetch from those sites",
"--moe-cache-split": "reads a profile file",
"--hf-overrides": "rewrites the checkpoint's configuration, which can name code to load",
},
// trtllm-serve spells its flags with underscores, so the shared list's
// hyphenated names do not reach them.
@@ -150,14 +209,43 @@ var flagsNever = map[string]map[string]string{
// unsafeFlag is why an engine may not be given a flag, or "".
func unsafeFlag(engine, flag, _ string) string {
name, _, _ := strings.Cut(flag, "=")
if !strings.HasPrefix(name, "-") {
return ""
}
// Written as the engine would read it: vLLM takes --load_format for
// --load-format, TensorRT-LLM the other way round. Short flags (-m,
// -hf) are matched as written.
norm := func(f string) string {
if strings.HasPrefix(f, "--") {
return "--" + strings.ReplaceAll(f[2:], "_", "-")
}
return f
}
given := norm(name)
// argparse also takes any unambiguous beginning of a long flag for the
// flag — --trust-remote is --trust-remote-code, --li is ComfyUI's
// --listen — so for the engines that parse with it, a beginning of a
// refused flag is refused. llama.cpp and TensorRT-LLM take no such
// thing, and --chat-template there is a flag of its own.
abbreviates := argparseEngines[engine] && strings.HasPrefix(given, "--") && len(given) >= 4
for _, set := range []string{"", engine} {
if why, ok := flagsNever[set][name]; ok && why != "" {
return why
for never, why := range flagsNever[set] {
if why == "" {
continue
}
n := norm(never)
if n == given || (abbreviates && strings.HasPrefix(n, "--") && strings.HasPrefix(n, given)) {
return why
}
}
}
return ""
}
// argparseEngines read their flags with Python's argparse, which takes a
// long flag's beginning for the flag.
var argparseEngines = map[string]bool{"vllm": true, "sglang": true, "comfyui": true, "strata": true, "freetoken": true}
// UnsafeArgs refuses the first of a profile's arguments an engine may not be
// given, saying why.
func UnsafeArgs(engine string, args []string) error {
@@ -172,3 +260,29 @@ func UnsafeArgs(engine string, args []string) error {
}
return nil
}
// safetensorsOnly has vLLM or SGLang read only safetensors from a model it
// downloads itself, by its Hugging Face id: left to choose, either takes
// the repository's pickle files (pytorch_model.bin) when it has no
// safetensors, and loading those runs whatever they hold. A path was
// fetched by the agent, which checked it, or put there by the operator.
func safetensorsOnly(p Profile) []string {
if p.AllowPickle || !hubID(p.ModelPath) {
return nil
}
return []string{"--load-format", "safetensors"}
}
// hubID reports whether a model path is a Hugging Face id, org/name, rather
// than a file or directory.
func hubID(s string) bool {
if s == "" || strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") || strings.Contains(s, "://") {
return false
}
// org/name:Q4_K_M is a GGUF in a repository, which vLLM reads as one.
if strings.Count(s, "/") != 1 || strings.Contains(s, ":") {
return false
}
_, err := os.Stat(s)
return err != nil
}
+1
View File
@@ -119,6 +119,7 @@ func (v *VLLM) serverCommand(s ShardSpec) (Command, error) {
if p.ServedModelName != "" {
args = append(args, "--served-model-name", p.ServedModelName)
}
args = append(args, safetensorsOnly(p)...)
env := baseEnv(s)
+127
View File
@@ -0,0 +1,127 @@
package agent
import (
"context"
"net"
"net/netip"
"net/url"
"strings"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
"gitea.mynodes.xyz/ilker/llmgw/internal/slurm"
)
// slurmPorts are Slurm's on a node that runs any of it: slurmctld's and
// slurmd's, and srun's range for the steps it starts.
var slurmPorts = []firewall.Port{
{From: slurm.CtldPort, To: slurm.DbdPort, What: "slurm: slurmctld, slurmd and slurmdbd"},
{From: slurm.SrunPortsFrom, To: slurm.SrunPortsTo, What: "slurm: srun, for the steps it starts"},
}
// nfsPort is NFSv4's, on the machine that exports the Slurm cluster's
// shared files.
var nfsPort = firewall.Port{From: slurm.NFSPort, To: slurm.NFSPort, What: "nfs: the Slurm cluster's shared files"}
// applyFirewall keeps llmgw's firewall on this node as the cluster's
// settings say: the engines' ports and the node exporter's for the networks
// the node reaches its control planes on, and those named, or no table at
// all. Off the receive loop, one at a time.
func (a *Agent) applyFirewall(m *llmgwv1.Firewall) {
a.fwMu.Lock()
defer a.fwMu.Unlock()
a.fwLast = m
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
r := a.fwRunner
if r == nil {
r = firewall.Local{}
}
if !m.GetEnabled() {
had, err := firewall.Disable(ctx, r)
switch {
case err != nil:
a.log.Warn("could not take llmgw's firewall off this node", "err", err)
case had:
a.log.Info("llmgw's firewall is off on this node, as the cluster's settings say")
}
return
}
a.mu.Lock()
servers := append([]string(nil), a.serverList...)
a.mu.Unlock()
nets, err := firewall.Networks(ctx, r, controlPlaneIPs(ctx, servers))
if err != nil {
a.log.Warn("could not tell which networks reach the control planes; the firewall is left as it is", "err", err)
return
}
for _, n := range m.GetNetworks() {
if p, err := netip.ParsePrefix(n); err == nil {
nets = append(nets, p)
} else if ad, err := netip.ParseAddr(n); err == nil {
nets = append(nets, netip.PrefixFrom(ad, ad.BitLen()))
}
}
from, to := int(m.GetEnginePortsFrom()), int(m.GetEnginePortsTo())
ports := []firewall.Port{firewall.NodeExporter}
if from > 0 && to >= from {
ports = append(ports, firewall.Engines(from, to))
}
// Once there, they stay: the ruleset keeps every port it held, so a
// node that leaves Slurm keeps them closed to everyone but the cluster.
if a.slurm != nil && a.slurm.HasPart() {
ports = append(ports, slurmPorts...)
}
if a.slurm != nil && a.slurm.Serves() {
ports = append(ports, nfsPort)
}
changed, err := firewall.Install(ctx, r, firewall.Spec{Ports: ports, Allow: nets})
switch {
case err != nil:
a.log.Warn("could not put llmgw's firewall on this node", "err", err)
case changed:
a.log.Info("llmgw's firewall is on this node", "networks", len(nets))
}
}
// slurmMessage is the leader's word on this node's part in Slurm. A node
// that gets one, or starts or stops exporting the shared files, has the
// firewall applied again, with Slurm's ports and NFS's.
func (a *Agent) slurmMessage(m *llmgwv1.Slurm) {
had, served := a.slurm.HasPart(), a.slurm.Serves()
a.slurm.Apply(m)
if (had || !a.slurm.HasPart()) && served == a.slurm.Serves() {
return
}
a.fwMu.Lock()
last := a.fwLast
a.fwMu.Unlock()
if last != nil && last.GetEnabled() {
go a.applyFirewall(last)
}
}
// controlPlaneIPs are the addresses of the control planes an agent dials.
func controlPlaneIPs(ctx context.Context, servers []string) []string {
var out []string
for _, s := range servers {
host := s
if u, err := url.Parse(s); err == nil && u.Host != "" {
host = u.Hostname()
}
host = strings.Trim(host, "[]")
if a, err := netip.ParseAddr(host); err == nil {
out = append(out, a.String())
continue
}
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
continue
}
for _, ip := range ips {
out = append(out, ip.Unmap().String())
}
}
return out
}
+84
View File
@@ -0,0 +1,84 @@
package agent
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/slurmnode"
)
// fakeShell answers the firewall package's scripts and records what was
// installed or removed.
type fakeShell struct {
present bool
installed string
deleted bool
}
func (f *fakeShell) Run(_ context.Context, script string, stdin []byte) ([]byte, error) {
switch {
case strings.HasPrefix(script, "ip -o route get 10.0.0.140"):
return []byte("10.0.0.140 dev eth0 src 10.0.0.141 uid 0\n2: eth0 inet 10.0.0.141/24 brd 10.0.0.255 scope global eth0\n"), nil
case strings.HasPrefix(script, "sudo -n /usr/sbin/nft list table"):
if f.present {
return nil, nil
}
return nil, errors.New("no table")
case strings.Contains(script, "nft delete table") && !strings.Contains(script, "cat > "):
f.deleted, f.present = true, false
return nil, nil
case strings.HasPrefix(script, "sudo cat "):
return nil, nil
default:
f.installed, f.present = string(stdin), true
return []byte("changed\n"), nil
}
}
func TestANodeKeepsOrDropsItsFirewallAsTheClusterSays(t *testing.T) {
sh := &fakeShell{}
a := &Agent{log: slog.New(slog.DiscardHandler), fwRunner: sh, serverList: []string{"https://10.0.0.140:7443"}}
a.applyFirewall(&llmgwv1.Firewall{Enabled: true, Networks: []string{"192.168.5.0/24", "10.9.9.9"},
EnginePortsFrom: 20000, EnginePortsTo: 20999})
for _, want := range []string{"10.0.0.0/24", "192.168.5.0/24", "10.9.9.9/32", "20000-20999", "9100"} {
if !strings.Contains(sh.installed, want) {
t.Errorf("the rules lack %s:\n%s", want, sh.installed)
}
}
a.applyFirewall(&llmgwv1.Firewall{Enabled: false})
if !sh.deleted {
t.Error("turned off, the table stayed")
}
}
func TestANodeGivenAPartInSlurmLetsSlurmInFromTheClusterOnly(t *testing.T) {
sh := &fakeShell{}
a := &Agent{log: slog.New(slog.DiscardHandler), fwRunner: sh, serverList: []string{"https://10.0.0.140:7443"},
slurm: slurmnode.New(nil, nil, "", slog.New(slog.DiscardHandler))}
a.applyFirewall(&llmgwv1.Firewall{Enabled: true, EnginePortsFrom: 20000, EnginePortsTo: 20999})
if strings.Contains(sh.installed, "6817") {
t.Fatalf("Slurm's ports before the node has a part in it:\n%s", sh.installed)
}
a.slurmMessage(&llmgwv1.Slurm{Name: "node05", Node: true, ConfServer: "10.0.0.148:6817"})
deadline := time.Now().Add(5 * time.Second)
for {
a.fwMu.Lock()
got := sh.installed
a.fwMu.Unlock()
if strings.Contains(got, "6817-6819") && strings.Contains(got, "60001-63000") {
break
}
if time.Now().After(deadline) {
t.Fatalf("given a part in Slurm, the rules still lack its ports:\n%s", got)
}
time.Sleep(10 * time.Millisecond)
}
}
+101 -6
View File
@@ -4,10 +4,12 @@ import (
"context"
"fmt"
"os"
"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/agent/engines"
)
@@ -18,6 +20,7 @@ var (
gpuProcesses = discovery.GPUProcesses
gpuMemory = discovery.GPUMemoryByProcess
processGroup = pgidOf
parentOf = ppidOf
// gpuCheckWait is how long a shard has, once it answers, to show up on
// its GPUs: the weights are on them by then, but nvidia-smi can lag.
gpuCheckWait = 15 * time.Second
@@ -114,19 +117,44 @@ func (a *Agent) foreignGPUMemory(ctx context.Context, shards []int) (foreign, us
return foreign, used
}
// holds reports whether any of pids is the shard's process or in its group:
// an engine that computes in a child process is still the shard.
// holds reports whether any of pids is the shard's process, in its group,
// or a process it started: an engine that computes in a child process is
// still the shard, and Strata's server starts its engine in a group of the
// engine's own.
func holds(pids []int, pid, group int) bool {
for _, p := range pids {
if p == pid || (group > 0 && processGroup(p) == group) {
if p == pid || (group > 0 && processGroup(p) == group) || descends(p, pid) {
return true
}
}
return false
}
// descends reports whether process p was started, at any remove, by pid.
func descends(p, pid int) bool {
// Bounded: a chain this deep is no engine's, and a /proc read racing an
// exit must not loop.
for range 16 {
p = parentOf(p)
switch {
case p == pid:
return true
case p <= 1:
return false
}
}
return false
}
// pgidOf reads a process's group from /proc; zero when it cannot.
func pgidOf(pid int) int {
func pgidOf(pid int) int { return statField(pid, 2) }
// ppidOf reads a process's parent from /proc; zero when it cannot.
func ppidOf(pid int) int { return statField(pid, 1) }
// statField is one of the numbers after the command name in /proc's stat
// line: 1 is the parent, 2 the group.
func statField(pid, field int) int {
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
if err != nil {
return 0
@@ -139,10 +167,10 @@ func pgidOf(pid int) int {
return 0
}
f := strings.Fields(s[i+1:])
if len(f) < 3 {
if len(f) <= field {
return 0
}
n, _ := strconv.Atoi(f[2])
n, _ := strconv.Atoi(f[field])
return n
}
@@ -153,3 +181,70 @@ func joinIndices(is []int) string {
}
return strings.Join(s, ", ")
}
// engineCompute is where an engine said it put its model, from its output
// so far — llama.cpp's, which says — and false for one that said nothing. A
// shard given GPUs that its engine says it computes on the CPU is an error,
// found this way even where nvidia-smi cannot be asked.
func engineCompute(eng engines.Engine, gpus []int, lines []string) (engines.Compute, bool, error) {
if _, ok := eng.(engines.CPUFallback); !ok {
return engines.Compute{}, false, nil
}
read, ok := engines.ReadLlamaCompute(lines)
if !ok {
return engines.Compute{}, false, nil
}
if len(gpus) > 0 && read.OnTheCPU() {
why := "its GPU backend saw no device it could use"
if read.Total > 0 && !read.NoGPU {
why = fmt.Sprintf("it offloaded %d of %d layers", read.Offloaded, read.Total)
}
return read, true, fmt.Errorf("placed on GPU %v but computing on the CPU: %s, by llama.cpp's own account "+
"(llmgw ctl engine-check on this node says why)", gpus, why)
}
return read, true, nil
}
// computeToWire is a compute as it travels to the control plane.
func computeToWire(c *engines.Compute) *llmgwv1.Compute {
if c == nil {
return nil
}
return &llmgwv1.Compute{Backend: c.Backend, Devices: c.Devices,
LayersOffloaded: int32(c.Offloaded), LayersTotal: int32(c.Total)} //nolint:gosec // layer counts
}
// gpuCompute is where a shard computes as nvidia-smi sees it: the CPU for a
// shard given no GPU, else the GPUs whose memory its processes hold, and how
// much. False when nvidia-smi cannot be asked. Engine-agnostic, and needing
// no log line: llama.cpp says where its layers went only at -lv 4.
func (a *Agent) gpuCompute(ctx context.Context, gpus []int, pid int) (engines.Compute, bool) {
if len(gpus) == 0 {
return engines.Compute{Backend: "CPU"}, true
}
mem, err := gpuMemory(ctx, a.cfg.Discovery.NvidiaSMI)
if err != nil || mem == nil || pid <= 0 {
return engines.Compute{}, false
}
group := processGroup(pid)
c := engines.Compute{Backend: "CUDA"}
for _, g := range mem {
if !slices.Contains(gpus, g.Index) {
continue
}
used := 0
for _, p := range g.Procs {
if holds([]int{p.PID}, pid, group) {
used += p.UsedMB
}
}
if used > 0 {
c.Devices = append(c.Devices, fmt.Sprintf("GPU %d: %.1f GB", g.Index, float64(used)/1024))
}
}
if len(c.Devices) == 0 {
// Placed on GPUs and holding none of their memory.
c.Backend = "CPU"
}
return c, true
}
+76
View File
@@ -122,3 +122,79 @@ func TestForeignGPUMemoryLeavesOutTheAgentsOwnShards(t *testing.T) {
t.Errorf("no reading came back as %v", foreign)
}
}
func TestAnEngineStartedInAGroupOfItsOwnIsStillTheShards(t *testing.T) {
// cuda01: Strata's server (259709) starts its engine (261072) in a
// group of the engine's own, and the engine holds the card. It read as
// a shard on the CPU, and as 22 GB of somebody else's memory.
oldMem, oldGroup, oldParent := gpuMemory, processGroup, parentOf
t.Cleanup(func() { gpuMemory, processGroup, parentOf = oldMem, oldGroup, oldParent })
processGroup = func(pid int) int { return pid }
parentOf = func(pid int) int {
return map[int]int{261072: 259709, 259709: 258734, 258734: 1, 999: 1}[pid]
}
gpuMemory = func(context.Context, string) ([]discovery.GPUMemory, error) {
return []discovery.GPUMemory{{Index: 0, UsedMB: 23000,
Procs: []discovery.GPUProcess{{PID: 261072, UsedMB: 22226}, {PID: 999, UsedMB: 700}}}}, nil
}
a := &Agent{log: slog.Default()}
if c, ok := a.gpuCompute(context.Background(), []int{0}, 259709); !ok || c.Backend != "CUDA" ||
len(c.Devices) != 1 || c.Devices[0] != "GPU 0: 21.7 GB" {
t.Errorf("the engine's child on GPU 0: %+v %v", c, ok)
}
if foreign, _ := a.foreignGPUMemory(context.Background(), []int{259709}); !slices.Equal(foreign, []int32{774}) {
t.Errorf("foreign %v; want only what the other process and nobody holds, [774]", foreign)
}
}
func TestLlamaCppsOwnWordOnTheCPUIsEnoughToRefuse(t *testing.T) {
eng := fallbackEngine{}
// Without nvidia-smi to ask, the engine's own output says it.
c, said, err := engineCompute(eng, []int{0}, []string{
"warning: no usable GPU found, --gpu-layers option will be ignored",
"load_tensors: offloaded 0/49 layers to GPU"})
if err == nil || !said || !strings.Contains(err.Error(), "computing on the CPU") || c.Backend != "CPU" {
t.Fatalf("a GPU shard llama.cpp put on the CPU: %v %+v", err, c)
}
c, said, err = engineCompute(eng, []int{0}, []string{
"llama_model_load_from_file_impl: using device CUDA0 (Quadro RTX 6000) - 23800 MiB free",
"load_tensors: offloaded 49/49 layers to GPU"})
if err != nil || !said || c.Backend != "CUDA" || c.Offloaded != 49 || computeToWire(&c).GetLayersOffloaded() != 49 {
t.Errorf("on its GPU: %v %+v", err, c)
}
// A CPU shard is not refused for being on the CPU, and says so.
if c, _, err := engineCompute(eng, nil, []string{"load_tensors: offloaded 0/49 layers to GPU"}); err != nil || c.Backend != "CPU" {
t.Errorf("a CPU shard: %v %+v", err, c)
}
// An engine that said nothing says nothing.
if _, said, err := engineCompute(eng, []int{0}, []string{"listening"}); err != nil || said {
t.Errorf("nothing said: %v", err)
}
}
func TestNvidiaSMISaysWhereAShardComputes(t *testing.T) {
oldMem, oldGroup := gpuMemory, processGroup
t.Cleanup(func() { gpuMemory, processGroup = oldMem, oldGroup })
processGroup = func(pid int) int { return pid }
gpuMemory = func(context.Context, string) ([]discovery.GPUMemory, error) {
return []discovery.GPUMemory{
{Index: 0, UsedMB: 20000, Procs: []discovery.GPUProcess{{PID: 700, UsedMB: 2048}, {PID: 900, UsedMB: 17000}}},
{Index: 1, UsedMB: 0},
}, nil
}
a := &Agent{log: slog.Default()}
if c, ok := a.gpuCompute(context.Background(), []int{0}, 700); !ok || c.Backend != "CUDA" ||
len(c.Devices) != 1 || c.Devices[0] != "GPU 0: 2.0 GB" {
t.Errorf("on GPU 0: %+v %v", c, ok)
}
if c, ok := a.gpuCompute(context.Background(), []int{1}, 700); !ok || c.Backend != "CPU" {
t.Errorf("placed on GPU 1, holding none of it: %+v", c)
}
if c, ok := a.gpuCompute(context.Background(), nil, 700); !ok || c.Backend != "CPU" {
t.Errorf("a CPU shard: %+v", c)
}
gpuMemory = func(context.Context, string) ([]discovery.GPUMemory, error) { return nil, nil }
if _, ok := a.gpuCompute(context.Background(), []int{0}, 700); ok {
t.Error("no nvidia-smi read as an answer")
}
}
+17
View File
@@ -0,0 +1,17 @@
package agent
import (
"os"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
func TestMain(m *testing.M) {
// Half a gigabyte read per test binary, and contending with the tests
// for the CPUs: these never look at the figure.
measureBandwidth = func() discovery.Bandwidth { return discovery.Bandwidth{} }
bandwidthDelay = func() time.Duration { return 0 }
os.Exit(m.Run())
}
+7
View File
@@ -164,6 +164,13 @@ func (a *Agent) provisionLoop(ctx context.Context) {
// provisionStatus records a change in one engine's installation.
func (a *Agent) provisionStatus(st provision.Status) {
if st == (provision.Status{}) {
// A package install that was no engine's has ended.
a.mu.Lock()
delete(a.provStatus, "")
a.mu.Unlock()
return
}
a.mu.Lock()
prev, had := a.provStatus[st.Engine]
a.provStatus[st.Engine] = st
+177
View File
@@ -0,0 +1,177 @@
package provision
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
// comfyuiSource is a ComfyUI release archive as GitHub makes one, and the
// recipe pointed at it.
func comfyuiSource(t *testing.T, in *Installer, rel *release, digest string) {
t.Helper()
archive := tarGz(t, map[string]string{
"ComfyUI-0.39.2/main.py": "print('comfy')\n",
"ComfyUI-0.39.2/comfyui_version.py": "__version__ = \"0.39.2\"\n",
"ComfyUI-0.39.2/requirements.txt": "comfyui-frontend-package==1.53.10\ntorch\ntorchvision\n" +
"numpy>=1.25.0\n\n#non essential dependencies:\nkornia>=0.7.1\n",
}, nil)
rel.assets["v0.39.2.tar.gz"] = archive
sum := sha256.Sum256(archive)
if digest == "" {
digest = hex.EncodeToString(sum[:])
}
old := comfyuiReleases
comfyuiReleases = map[string]struct {
sha256 string
published string
}{"0.39.2": {digest, "2026-10-07T21:58:07Z"}}
t.Cleanup(func() { comfyuiReleases = old })
}
var turingNode = discovery.Host{OSID: "ubuntu", OSVersion: "24.04", Arch: "amd64",
NVIDIADriver: "580.82.07", MinComputeCapability: 7.5}
func TestComfyUIIsInstalledFromItsSourceCheckedAgainstTheRecipe(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
in, rel := pythonInstaller(t, turingNode, sys)
comfyuiSource(t, in, rel, "")
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
if got[0].State != Ready {
t.Fatalf("not ready: %+v", got[0])
}
dir := filepath.Join(in.Root, "comfyui", "0.39.2-cu13")
if got[0].Binary != filepath.Join(dir, "bin", "python") {
t.Errorf("binary %s, want the environment's interpreter", got[0].Binary)
}
if _, err := os.Stat(filepath.Join(dir, "comfyui", "main.py")); err != nil {
t.Errorf("ComfyUI's code is not beside the interpreter: %v", err)
}
all := strings.Join(sys.commands, "\n")
for _, want := range []string{
// Not PyPI's date, which ComfyUI has none of: the recipe's, a day on.
"--exclude-newer 2026-10-08T21:58:07Z",
"comfyui-frontend-package==1.53.10", "numpy>=1.25.0", "kornia>=0.7.1", "torch",
"--require-hashes",
} {
if !strings.Contains(all, want) {
t.Errorf("no %q in:\n%s", want, all)
}
}
if strings.Contains(all, "/pypi/") {
t.Error("PyPI was asked when ComfyUI was published")
}
}
func TestComfyUIOnAnOlderDriverTakesPyTorchsCUDA12Torch(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
host := turingNode
host.NVIDIADriver = "550.163.01"
in, rel := pythonInstaller(t, host, sys)
comfyuiSource(t, in, rel, "")
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
if got[0].State != Ready || got[0].Variant != "cu129" {
t.Fatalf("got %+v", got[0])
}
var project string
for _, c := range sys.commands {
if strings.HasPrefix(c, "pyproject: ") {
project = c
}
}
// torch from PyTorch's index alone, named once, as the build names it.
if !strings.Contains(project, `torch = { index = "extra" }`) || strings.Count(project, `"torch"`) != 1 {
t.Errorf("torch is not from PyTorch's index alone:\n%s", project)
}
}
// A V100 on driver 580: CUDA 13 runs on the driver, but neither its torch
// nor CUDA 12.9's has kernels for Volta. Tried in turn, each was installed,
// 10 GB at a time, and failed on the card.
func TestComfyUIOnAVoltaTakesPyTorchsCUDA126Torch(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
host := turingNode
host.MinComputeCapability = 7.0
in, rel := pythonInstaller(t, host, sys)
comfyuiSource(t, in, rel, "")
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
if got[0].State != Ready || got[0].Variant != "cu126" {
t.Fatalf("got %+v", got[0])
}
all := strings.Join(sys.commands, "\n")
if !strings.Contains(all, pytorchCU126) || strings.Contains(all, pytorchCU129) {
t.Errorf("torch is not CUDA 12.6's:\n%s", all)
}
}
func TestComfyUIWhoseSourceIsNotTheOneTheRecipeNamesIsNotInstalled(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
in, rel := pythonInstaller(t, turingNode, sys)
comfyuiSource(t, in, rel, strings.Repeat("ab", 32))
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
if got[0].State != Failed || !strings.Contains(got[0].Detail, "does not match") {
t.Fatalf("got %s %q", got[0].State, got[0].Detail)
}
for _, c := range sys.commands {
if strings.Contains(c, "pip install") {
t.Errorf("installed from a source that did not match: %s", c)
}
}
}
func TestComfyUIIsInstalledOnlyWhereItCanRun(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
in, _ := pythonInstaller(t, ubuntu, sys)
if got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}}); got[0].State != Skipped {
t.Errorf("a node with no GPU: %+v", got[0])
}
in, _ = pythonInstaller(t, turingNode, sys)
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.1.0"}})
if got[0].State != Unsupported || !strings.Contains(got[0].Detail, "knows the source digest of ComfyUI 0.39.2") {
t.Errorf("a version without a digest: %s %q", got[0].State, got[0].Detail)
}
}
func TestARequirementsFileIsReadForPackagesAndNothingElse(t *testing.T) {
got, err := requirementsIn("torch\ntorchvision>=0.20\nPillow\n# a comment\naiohttp>=3.11 # inline\n", pytorchPackages)
if err != nil || strings.Join(got, ",") != "Pillow,aiohttp>=3.11" {
t.Errorf("%v %v", got, err)
}
for _, bad := range []string{"-r other.txt", "--index-url https://evil", "x @ https://evil/x.whl", "-e ."} {
if _, err := requirementsIn(bad+"\n", nil); err == nil {
t.Errorf("%q was accepted", bad)
}
}
}
// A card every build runs on keeps the order it had: a Turing node's
// installed cu13 is still the first found, and is not installed again
// because a build for older cards was added after it.
func TestComfyUIBuildsAreTriedBestFirst(t *testing.T) {
variants := func(h discovery.Host) string {
builds, err := comfyuiCandidates("0.39.2", "", h)
if err != nil {
return err.Error()
}
var v []string
for _, b := range builds {
v = append(v, b.Variant)
}
return strings.Join(v, ",")
}
volta := turingNode
volta.MinComputeCapability = 7.0
older := turingNode
older.NVIDIADriver = "550.163.01"
for h, want := range map[*discovery.Host]string{&turingNode: "cu13,cu129,cu126", &volta: "cu126", &older: "cu129,cu126"} {
if got := variants(*h); got != want {
t.Errorf("cc %.1f driver %s: %s, want %s", h.MinComputeCapability, h.NVIDIADriver, got, want)
}
}
}
@@ -0,0 +1,53 @@
package provision
import (
"errors"
"slices"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
func TestFreeTokenIsInstalledOnlyWhereItsKernelsRun(t *testing.T) {
old := freetokenToolkit
toolkit := "/usr/local/cuda-13.4"
freetokenToolkit = func() string { return toolkit }
t.Cleanup(func() { freetokenToolkit = old })
ampere := discovery.Host{Arch: "amd64", NVIDIADriver: "580.95.05", MinComputeCapability: 8.6}
got, err := freetokenCandidates("0.2.1", "", ampere)
if err != nil {
t.Fatal(err)
}
b := got[0]
if b.Binary != "ft" || !slices.Equal(b.Requirements, []string{"freetoken[accel]==0.2.1"}) || !b.GPUCheck || b.Python == "" {
t.Errorf("build %+v", b)
}
// Not meant for these, and not reported as a fault: no GPU, a Turing
// card (cuda01, where 0.2.1 had no kernel image for it), an Arm node.
for name, h := range map[string]discovery.Host{
"no gpu": {Arch: "amd64"},
"turing": {Arch: "amd64", NVIDIADriver: "615.71.09", MinComputeCapability: 7.5},
"aarch64": {Arch: "arm64", NVIDIADriver: "580.95.05", MinComputeCapability: 9.0},
} {
_, err := freetokenCandidates("0.2.1", "", h)
var nh NotHere
if !errors.As(err, &nh) {
t.Errorf("%s: %v, want NotHere", name, err)
}
}
// Meant for these, and said why not.
if _, err := freetokenCandidates("0.2.1", "", discovery.Host{Arch: "amd64", NVIDIADriver: "570.1", MinComputeCapability: 9.0}); err == nil ||
!strings.Contains(err.Error(), "driver 580") {
t.Errorf("old driver: %v", err)
}
toolkit = ""
_, err = freetokenCandidates("0.2.1", "", ampere)
var nh NotHere
if err == nil || errors.As(err, &nh) || !strings.Contains(err.Error(), "CUDA 13 toolkit") {
t.Errorf("no toolkit: %v", err)
}
}
+10 -3
View File
@@ -174,7 +174,7 @@ func (in *Installer) one(ctx context.Context, w Want) Status {
}
return st
}
if w.Offline != nil && recipe.Repo == "" {
if w.Offline != nil && (recipe.Repo == "" || builds[0].Python != "") {
// Said, not attempted: uv would spend minutes failing to reach PyPI,
// and the failure would read like a network fault rather than the
// design of an offline site.
@@ -483,7 +483,8 @@ func (in *Installer) packages(ctx context.Context, byFamily map[string][]string,
return fmt.Errorf("%s is missing and no package manager was found to install it",
strings.Join(missing, ", "))
}
in.progress(st, "installing %s with %s", strings.Join(missing, ", "), tool)
// The state already says installing.
in.progress(st, "%s, with %s", strings.Join(missing, ", "), tool)
for _, c := range cmds {
if err := in.runWaitingForLock(ctx, pm.env, c, st); err != nil {
return err
@@ -497,7 +498,13 @@ func (in *Installer) packages(ctx context.Context, byFamily map[string][]string,
// byFamily is keyed by discovery.Host.Family: "deb" or "rpm".
func (in *Installer) InstallPackages(ctx context.Context, byFamily map[string][]string) error {
var st Status
return in.packages(ctx, byFamily, &st)
err := in.packages(ctx, byFamily, &st)
if st.State == Installing {
// Its progress was no engine's: an empty status takes it off the
// node's page again, done or failed (the caller says which).
in.report(Status{})
}
return err
}
// lockWait bounds how long a package command waits for another to finish.
@@ -0,0 +1,36 @@
package provision
import (
"context"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
// A package install that is no engine's says it is done, so its progress
// does not stay on the node's page (found on the PoC installing Slurm).
func TestInstallPackagesClearsItsProgress(t *testing.T) {
var got []Status
in := &Installer{Host: discovery.Host{OSID: "ubuntu"}, OnStatus: func(s Status) { got = append(got, s) },
Run: func(_ context.Context, _ []string, name string, _ ...string) ([]byte, error) {
if name == "sh" { // the query: not installed
return nil, context.Canceled
}
return nil, nil
}}
if err := in.InstallPackages(context.Background(), map[string][]string{"deb": {"munge"}}); err != nil {
t.Fatal(err)
}
if len(got) < 2 || got[0].State != Installing || got[len(got)-1] != (Status{}) {
t.Fatalf("statuses %+v", got)
}
got = nil
in.Run = func(context.Context, []string, string, ...string) ([]byte, error) { return nil, nil }
if err := in.InstallPackages(context.Background(), map[string][]string{"deb": {"munge"}}); err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatalf("installed already, yet said %+v", got)
}
}
+130 -6
View File
@@ -66,12 +66,17 @@ func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Buil
return fmt.Errorf("installing uv, which installs %s: %s", w.Engine, uv.Detail)
}
in.progress(st, "asking PyPI when %s %s was published", r.PyPI, w.Version)
published, err := in.published(ctx, r.PyPI, w.Version)
if err != nil {
return err
var asOf string
if !b.AsOf.IsZero() {
asOf = b.AsOf.UTC().Format(time.RFC3339)
} else {
in.progress(st, "asking PyPI when %s %s was published", r.PyPI, w.Version)
published, err := in.published(ctx, r.PyPI, w.Version)
if err != nil {
return err
}
asOf = published.Add(24 * time.Hour).UTC().Format(time.RFC3339)
}
asOf := published.Add(24 * time.Hour).UTC().Format(time.RFC3339)
cache := filepath.Join(in.root(), ".uv-cache")
env := []string{
@@ -96,7 +101,19 @@ func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Buil
}
python := filepath.Join(tmp, "bin", "python")
reqs := b.Requirements
reqs := slices.Clone(b.Requirements)
if b.Source != nil {
more, err := in.unpackSource(ctx, w, b, tmp, st)
if err != nil {
return err
}
reqs = append(reqs, more...)
}
if len(b.Assets) > 0 {
if err := in.unpackAssets(ctx, r, w, b, tmp, st); err != nil {
return err
}
}
if b.Wheel != nil {
// The engine from its GitHub release, pinned to the digest GitHub
// publishes for the file: the same trust as an archive, and uv
@@ -320,3 +337,110 @@ func (in *Installer) gpuCheck(ctx context.Context, python string, w Want, b Buil
}
return nil
}
// unpackSource fetches an engine's source archive, checks it against the
// digest the recipe holds, puts its code in the environment, and returns the
// requirements its requirements file names — less the packages a second
// index gives, which the build names itself.
func (in *Installer) unpackSource(ctx context.Context, w Want, b Build, env string, st *Status) ([]string, error) {
src := b.Source
url := src.URL
if strings.HasPrefix(url, "/") {
url = strings.TrimSuffix(in.downloadBase(), "/") + url
}
scratch := env + ".source"
_ = os.RemoveAll(scratch)
if err := os.MkdirAll(scratch, 0o755); err != nil {
return nil, err
}
defer os.RemoveAll(scratch) //nolint:errcheck // scratch
archive := filepath.Join(scratch, "source.tar.gz")
name := w.Engine + " " + w.Version + "'s source"
if err := in.download(ctx, url, name, archive, src.SHA256, st); err != nil {
return nil, err
}
in.progress(st, "unpacking %s", name)
if err := untarGz(archive, scratch); err != nil {
return nil, fmt.Errorf("unpacking %s: %w", name, err)
}
dst := filepath.Join(env, src.Dir)
if err := os.Rename(filepath.Join(scratch, src.Top), dst); err != nil {
return nil, fmt.Errorf("%s has no %s at its top: %w", name, src.Top, err)
}
if src.Requirements == "" {
return nil, nil
}
f, err := os.ReadFile(filepath.Join(dst, src.Requirements))
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
return requirementsIn(string(f), b.IndexPackages)
}
// unpackAssets fetches a Python engine's release binaries, each checked
// against the digest GitHub publishes for it, and unpacks them into the
// environment's AssetDir.
func (in *Installer) unpackAssets(ctx context.Context, r Recipe, w Want, b Build, env string, st *Status) error {
tag := b.AssetTag
if tag == "" {
tag = w.Version
}
in.progress(st, "asking GitHub for the checksums of %s %s", r.Repo, tag)
sums, err := in.checksums(ctx, r.Repo, tag)
if err != nil {
return err
}
dst := filepath.Join(env, b.AssetDir)
if err := os.MkdirAll(dst, 0o755); err != nil {
return err
}
for _, asset := range b.Assets {
want, ok := sums[asset]
if !ok {
// Refused rather than installed unchecked: this is a binary that
// will run on every node.
return fmt.Errorf("%s %s publishes no checksum for %s, so it cannot be verified", r.Repo, tag, asset)
}
url := strings.TrimSuffix(in.downloadBase(), "/") + "/" + r.Repo + "/releases/download/" + tag + "/" + asset
archive := filepath.Join(env+".source", asset)
if err := os.MkdirAll(filepath.Dir(archive), 0o755); err != nil {
return err
}
if err := in.download(ctx, url, asset, archive, want, st); err != nil {
return err
}
in.progress(st, "unpacking %s", asset)
err := untarGz(archive, dst)
_ = os.RemoveAll(filepath.Dir(archive))
if err != nil {
return fmt.Errorf("unpacking %s: %w", asset, err)
}
}
return nil
}
// requirementsIn reads a requirements file's lines. Options (-r, -e, --index-url
// and the like) are refused: they would fetch from somewhere other than PyPI,
// or install what the lock never saw.
func requirementsIn(text string, skip []string) ([]string, error) {
var out []string
for _, line := range strings.Split(text, "\n") {
line, _, _ = strings.Cut(line, "#")
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "-") || strings.Contains(line, "://") || strings.Contains(line, " @ ") {
return nil, fmt.Errorf("the requirements name %q, which is not a package from PyPI", line)
}
name := strings.ToLower(line)
if i := strings.IndexAny(name, "<>=!~;[ "); i >= 0 {
name = name[:i]
}
if slices.Contains(skip, name) {
continue
}
out = append(out, line)
}
return out, nil
}
+1
View File
@@ -53,6 +53,7 @@ func (f *fakePython) run(ctx context.Context, env []string, name string, args ..
return []byte("error: Hash mismatch for torch\n"), errExit
}
venv := filepath.Dir(filepath.Dir(args[slices.Index(args, "--python")+1]))
_ = os.WriteFile(filepath.Join(venv, "bin", "python"), []byte("#!/bin/sh\n"), 0o755)
return nil, os.WriteFile(filepath.Join(venv, "bin", "vllm"), []byte("#!/bin/sh\n"), 0o755)
case strings.HasSuffix(name, "/bin/vllm") && args[0] == "--version":
return []byte("0.30.0\n"), nil
+260 -4
View File
@@ -13,6 +13,7 @@ import (
"fmt"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
@@ -68,12 +69,36 @@ type Build struct {
// Note says why this build, when it is not the plain one: shown beside
// it wherever the installation is.
Note string
// Source, for a Python engine that is not on PyPI, is its own code: an
// archive pinned by its digest, unpacked into the environment, whose
// requirements file is installed with Requirements. AsOf is then the
// date its dependencies are resolved as of — the day after its version
// was published, which PyPI cannot be asked for.
Source *Source
AsOf time.Time
// AssetTag and AssetDir, for a Python engine that also ships a binary
// in its releases, are the release tag Assets are taken from, checked
// against the digests GitHub publishes, and the directory in the
// environment they are unpacked into.
AssetTag, AssetDir string
// FreeGB is the disk space the installation needs before it starts: a
// Python engine is several gigabytes, and one that fills the disk halfway
// leaves the node worse off than one that never began.
FreeGB int
}
// Source is an engine's code as an archive.
type Source struct {
// URL is fetched as is; SHA256 is what it must hash to, written into
// the recipe because a project's source archives come with no published
// digest of their own.
URL, SHA256 string
// Top is the archive's one top directory, put in the environment as Dir.
Top, Dir string
// Requirements is the requirements file, in Dir.
Requirements string
}
// Wheel is a package published as a GitHub release asset: the asset whose
// name starts with Prefix and ends with Suffix.
type Wheel struct {
@@ -101,10 +126,13 @@ type Recipe struct {
// from PyPI by uv, which is itself installed like llama.cpp: a GitHub release
// checked against its published checksum.
var Recipes = map[string]Recipe{
"llamacpp": {Repo: "ggml-org/llama.cpp", Candidates: llamaCppCandidates},
"vllm": {PyPI: "vllm", Candidates: vllmCandidates},
"sglang": {PyPI: "sglang", Candidates: sglangCandidates},
"uv": {Repo: "astral-sh/uv", Candidates: uvCandidates},
"llamacpp": {Repo: "ggml-org/llama.cpp", Candidates: llamaCppCandidates},
"vllm": {PyPI: "vllm", Candidates: vllmCandidates},
"sglang": {PyPI: "sglang", Candidates: sglangCandidates},
"comfyui": {Candidates: comfyuiCandidates},
"strata": {Repo: StrataRepo, Candidates: strataCandidates},
"freetoken": {PyPI: "freetoken", Candidates: freetokenCandidates},
"uv": {Repo: "astral-sh/uv", Candidates: uvCandidates},
}
// Installable are the engines this agent can install, as it tells the
@@ -151,6 +179,9 @@ func Builds(engine, version, arch string) ([]Build, error) {
continue
}
for _, b := range bs {
if b.Python != "" {
return nil, fmt.Errorf("%s is a Python environment from PyPI, which an offline bundle does not carry", engine)
}
if !seen[b.Variant] {
seen[b.Variant] = true
out = append(out, b)
@@ -556,3 +587,228 @@ func orUnknown(s string) string {
}
return s
}
// --- ComfyUI ---
// comfyuiReleases are the ComfyUI versions this agent installs: each source
// archive's digest, taken when it was added here and confirmed from a second
// machine, and when the version was published. GitHub publishes no digest
// for a tag's archive, so a version not listed is refused rather than
// installed unchecked.
var comfyuiReleases = map[string]struct {
sha256 string
published string
}{
"0.39.2": {"1d655e22cf546700fc1d6d2536e27701695c5832a51f63bb16c41866ac09be0b", "2026-10-07T21:58:07Z"},
}
// ComfyUIVersion is the newest ComfyUI this agent installs: what a cluster
// is set to when a model first needs it.
func ComfyUIVersion() string {
newest := ""
for v := range comfyuiReleases {
if newest == "" || versionBefore(newest, v) {
newest = v
}
}
return newest
}
// ComfyUIRepo is where ComfyUI's source is published.
const ComfyUIRepo = "Comfy-Org/ComfyUI"
// comfyuiDir is where ComfyUI's code is put in its environment: main.py is
// <environment>/comfyui/main.py, beside bin/python.
const comfyuiDir = "comfyui"
// pytorchCU126 is PyTorch's CUDA 12.6 build: the last that carries kernels
// for Volta and Pascal. From torch 2.8 its CUDA 12.8 and 12.9 builds start
// at Turing (7.5), as CUDA 13 does, so a V100 has no torch but this one.
const pytorchCU126 = "https://download.pytorch.org/whl/cu126"
// comfyuiCandidates are the ComfyUI builds for a machine: its source, in a
// Python environment with the torch for the node's driver and cards —
// PyPI's, built for CUDA 13; PyTorch's CUDA 12.9 build for a driver CUDA 13
// does not run on; and its CUDA 12.6 build for cards older than Turing.
func comfyuiCandidates(version, variant string, h discovery.Host) ([]Build, error) {
if _, err := gpuArch("ComfyUI", h); err != nil {
return nil, err
}
rel, ok := comfyuiReleases[version]
if !ok {
known := make([]string, 0, len(comfyuiReleases))
for v := range comfyuiReleases {
known = append(known, v)
}
slices.Sort(known)
return nil, fmt.Errorf("this agent knows the source digest of ComfyUI %s, not of %s; "+
"install that one by hand and name it in agent.yaml (engines.comfyui.dir)",
strings.Join(known, ", "), version)
}
published, err := time.Parse(time.RFC3339, rel.published)
if err != nil {
return nil, err
}
build := func(variant string) Build {
return Build{
Variant: variant,
Python: pythonForEngines,
Source: &Source{
URL: "/" + ComfyUIRepo + "/archive/refs/tags/v" + version + ".tar.gz",
SHA256: rel.sha256, Top: "ComfyUI-" + version, Dir: comfyuiDir,
Requirements: "requirements.txt",
},
AsOf: published.Add(24 * time.Hour),
// ComfyUI is started as a script, so what the agent is given is
// the environment's interpreter; the proof is importing what it
// imports first, from where its code is.
Binary: "python",
VersionArgs: []string{"-c", `import os, sys, torch, aiohttp, safetensors
d = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(sys.executable))), "` + comfyuiDir + `")
assert os.path.isfile(os.path.join(d, "main.py")), d + " has no main.py"
print("comfyui version", "` + version + `", "on torch", torch.__version__)`},
Packages: compilerPackages,
FreeGB: 15,
GPUCheck: true,
}
}
cu129 := build("cu129")
cu129.ExtraIndex, cu129.IndexPackages = pytorchCU129, pytorchPackages
cu126 := build("cu126")
cu126.ExtraIndex, cu126.IndexPackages = pytorchCU126, pytorchPackages
return pick("ComfyUI "+version, variant, []candidate{
{build("cu13"), gpuNeeds{minDriverForCUDA13, 7.5}, "ComfyUI " + version + " on CUDA 13's torch"},
{cu129, gpuNeeds{minDriverForCUDA12, 7.5}, "ComfyUI " + version + " on CUDA 12.9's torch"},
{cu126, gpuNeeds{minDriverForCUDA12, 5.0}, "ComfyUI " + version + " on CUDA 12.6's torch"},
}, h)
}
// --- Strata ---
// StrataRepo is where Strata is published: its source, and in each release
// the prebuilt engine for each CUDA.
const StrataRepo = "Niko1221/Strata"
// strataReleases are the Strata versions this agent installs: the source
// archive's digest, taken when it was added here and confirmed from a second
// machine, when the version was published, and the engine its release
// carries, whose files GitHub publishes digests for.
var strataReleases = map[string]struct {
sha256 string
published string
engine string
}{
"0.2.7": {"41ae861478f5e5d9506864f97dfb5dd7cfe6b83193300e2b0d4f04c0380e3af9", "2026-10-27T14:54:42Z", "0.1.52"},
}
// strataCandidates are the Strata builds for a machine: its source, in a
// Python environment, with the prebuilt engine for the node's driver and
// cards — built for CUDA 13, Turing to Blackwell; or for CUDA 12.9, Pascal
// to Ada, which is what a V100, or a driver older than 580, gets.
func strataCandidates(version, variant string, h discovery.Host) ([]Build, error) {
if _, err := gpuArch("Strata", h); err != nil {
return nil, err
}
if h.Arch != "amd64" {
return nil, NotHere{"Strata publishes its Linux CUDA engine for x86-64 only"}
}
rel, ok := strataReleases[version]
if !ok {
known := make([]string, 0, len(strataReleases))
for v := range strataReleases {
known = append(known, v)
}
slices.Sort(known)
return nil, fmt.Errorf("this agent knows the source digest of Strata %s, not of %s; "+
"install that one by hand and name it in agent.yaml (engines.strata.dir)",
strings.Join(known, ", "), version)
}
published, err := time.Parse(time.RFC3339, rel.published)
if err != nil {
return nil, err
}
build := func(cuda string) Build {
return Build{
Variant: cuda,
Python: pythonForEngines,
Source: &Source{
URL: "/" + StrataRepo + "/archive/refs/tags/v" + version + ".tar.gz",
SHA256: rel.sha256, Top: "Strata-" + version, Dir: "strata",
Requirements: "requirements.txt",
},
AsOf: published.Add(24 * time.Hour),
Assets: []string{"strata-engine-" + rel.engine + "-linux-x86_64-cuda-" + cuda + ".tar.gz"},
AssetTag: "v" + version,
AssetDir: "strata-engine",
// Strata is started as a script, so what the agent is given is
// the environment's interpreter; the proof is importing what its
// server needs, and finding its engine beside it.
Binary: "python",
VersionArgs: []string{"-c", `import os, sys, numpy, jinja2, regex
e = os.path.dirname(os.path.dirname(os.path.abspath(sys.executable)))
for f in ("strata/setup.py", "strata/serve/server.py", "strata-engine/strata"):
assert os.path.isfile(os.path.join(e, f)), f + " is missing"
print("strata version", "` + version + `", "engine", "` + rel.engine + `")`},
FreeGB: 5,
}
}
return pick("Strata "+version, variant, []candidate{
{build("cuda13"), gpuNeeds{minDriverForCUDA13, 7.5}, "Strata " + version + " built for CUDA 13"},
// 12.9 rather than 12: its machine code is for 6.0 to 8.9 and its
// PTX was compiled by 12.9, so the driver must be 575.
{build("cuda12"), gpuNeeds{575, 6.0}, "Strata " + version + " built for CUDA 12.9"},
}, h)
}
// --- FreeToken ---
// FreeTokenRepo is where FreeToken is developed; its builds are on PyPI.
const FreeTokenRepo = "FlashML-org/FreeToken"
// freetokenMinCompute is the oldest GPU FreeToken runs on. It says RTX 30
// and newer, and its kernels agree: on a Quadro RTX 6000 (7.5) version 0.2.1
// loaded gpt-oss-20b and failed its first prefill with no kernel image for
// the card, and in fp16 with fused experts its first request hit an illegal
// memory access. Its FP8 kernels need 8.9 and its NVFP4 ones 12.0, which
// the model's quantisation decides.
const freetokenMinCompute = 8.0
// freetokenToolkit finds the CUDA toolkit FreeToken compiles its kernels
// with; a variable for tests.
var freetokenToolkit = func() string { return discovery.CUDAToolkit(13) }
// freetokenCandidates are the FreeToken builds for a machine: its PyPI
// wheels with the kernel extras (FlashInfer, SGLang's kernels), CUDA 13,
// x86-64 only. A node of older cards is not one it is meant for, and is not
// reported as a fault; one of new cards without the toolkit is.
func freetokenCandidates(version, variant string, h discovery.Host) ([]Build, error) {
if _, err := gpuArch("FreeToken", h); err != nil {
return nil, err
}
if h.Arch != "amd64" {
return nil, NotHere{"FreeToken publishes wheels for x86-64 only; this node is " + h.Arch}
}
if cc := h.MinComputeCapability; cc > 0 && cc < freetokenMinCompute {
return nil, NotHere{fmt.Sprintf("FreeToken runs on GPUs of compute capability %.1f and newer "+
"(RTX 30, A100 and later); this node has %.1f", freetokenMinCompute, cc)}
}
// It compiles kernels the first time a model loads, with nvcc, as its
// own install guide says; without a toolkit the installation looks
// finished and the first replica fails.
if freetokenToolkit() == "" {
return nil, fmt.Errorf("FreeToken compiles its kernels where it runs and needs the CUDA 13 toolkit's nvcc " +
"(in CUDA_HOME, /usr/local/cuda or /usr/local/cuda-13.*); install cuda-toolkit-13 from NVIDIA's repository")
}
b := Build{
Variant: "cu13",
Python: pythonForEngines,
Requirements: []string{"freetoken[accel]==" + version},
Binary: "ft",
Packages: compilerPackages,
FreeGB: 20,
GPUCheck: true,
}
return pick("FreeToken "+version, variant, []candidate{
{b, gpuNeeds{minDriverForCUDA13, freetokenMinCompute}, "FreeToken " + version},
}, h)
}
+118
View File
@@ -0,0 +1,118 @@
package provision
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
// strataRelease is Strata's source archive as GitHub makes one, and its
// release's engine for CUDA 13, with the recipe pointed at the archive.
func strataRelease(t *testing.T, rel *release) {
t.Helper()
archive := tarGz(t, map[string]string{
"Strata-0.2.7/setup.py": "print('setup')\n",
"Strata-0.2.7/serve/server.py": "print('server')\n",
"Strata-0.2.7/requirements.txt": "numpy==2.5.3; python_version >= \"3.12\"\njinja2==3.1.6\n",
}, nil)
rel.assets["v0.2.7.tar.gz"] = archive
sum := sha256.Sum256(archive)
engine := tarGz(t, map[string]string{"strata": "\x7fELF", "libcudart.so.13": "lib"}, nil)
name := "strata-engine-0.1.52-linux-x86_64-cuda-cuda13.tar.gz"
rel.assets[name] = engine
esum := sha256.Sum256(engine)
rel.digests[name] = "sha256:" + hex.EncodeToString(esum[:])
old := strataReleases
strataReleases = map[string]struct {
sha256 string
published string
engine string
}{"0.2.7": {hex.EncodeToString(sum[:]), "2026-10-27T14:54:42Z", "0.1.52"}}
t.Cleanup(func() { strataReleases = old })
}
func TestStrataIsInstalledWithItsEngineBesideItsCode(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
in, rel := pythonInstaller(t, turingNode, sys)
strataRelease(t, rel)
got := in.Converge(t.Context(), []Want{{Engine: "strata", Version: "0.2.7"}})
if got[0].State != Ready {
t.Fatalf("not ready: %+v", got[0])
}
dir := filepath.Join(in.Root, "strata", "0.2.7-cuda13")
if got[0].Binary != filepath.Join(dir, "bin", "python") {
t.Errorf("binary %s, want the environment's interpreter", got[0].Binary)
}
for _, f := range []string{"strata/setup.py", "strata/serve/server.py", "strata-engine/strata", "strata-engine/libcudart.so.13"} {
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
t.Errorf("%s is not in the environment: %v", f, err)
}
}
all := strings.Join(sys.commands, "\n")
for _, want := range []string{"--exclude-newer 2026-10-28T14:54:42Z", "jinja2==3.1.6"} {
if !strings.Contains(all, want) {
t.Errorf("the installation never used %q:\n%s", want, all)
}
}
}
func TestStrataRefusesAnEngineWhoseDigestDoesNotMatch(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
in, rel := pythonInstaller(t, turingNode, sys)
strataRelease(t, rel)
rel.digests["strata-engine-0.1.52-linux-x86_64-cuda-cuda13.tar.gz"] = "sha256:" + strings.Repeat("0", 64)
got := in.Converge(t.Context(), []Want{{Engine: "strata", Version: "0.2.7", Variant: "cuda13"}})
if got[0].State != Failed || !strings.Contains(got[0].Detail, "does not match the checksum") {
t.Fatalf("an engine that does not match its digest was not refused: %+v", got[0])
}
}
func TestStrataPicksTheEngineForTheCardsAndDriver(t *testing.T) {
for _, tc := range []struct {
name string
host discovery.Host
want []string
err string
}{
{"turing on 580", turingNode, []string{"cuda13", "cuda12"}, ""},
{"volta", discovery.Host{Arch: "amd64", NVIDIADriver: "580.82.07", MinComputeCapability: 7.0}, []string{"cuda12"}, ""},
{"driver 575", discovery.Host{Arch: "amd64", NVIDIADriver: "575.51.03", MinComputeCapability: 8.6}, []string{"cuda12"}, ""},
{"driver 570", discovery.Host{Arch: "amd64", NVIDIADriver: "570.86.10", MinComputeCapability: 8.6}, nil, "575"},
{"no gpu", discovery.Host{Arch: "amd64"}, nil, "no"},
{"arm", discovery.Host{Arch: "arm64", NVIDIADriver: "580.82.07", MinComputeCapability: 9.0}, nil, "x86-64"},
} {
t.Run(tc.name, func(t *testing.T) {
bs, err := strataCandidates("0.2.7", "", tc.host)
if tc.err != "" {
if err == nil || !strings.Contains(err.Error(), tc.err) {
t.Fatalf("got %v, %v; want an error saying %q", bs, err, tc.err)
}
return
}
if err != nil {
t.Fatal(err)
}
var got []string
for _, b := range bs {
got = append(got, b.Variant)
}
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Errorf("builds %v, want %v", got, tc.want)
}
})
}
}
func TestStrataIsNotInAnOfflineBundle(t *testing.T) {
if _, err := Builds("strata", "0.2.7", "amd64"); err == nil {
t.Fatal("an offline bundle would carry Strata, which needs PyPI")
}
}
+32
View File
@@ -94,3 +94,35 @@ func TestWithNoCacheConfiguredNothingIsDeleted(t *testing.T) {
t.Errorf("an agent with no cache directory deleted something: %v", err)
}
}
// A model of several files is fetched into a directory of its own, and an
// unused one goes whole: it was refused as not empty, and nothing was freed.
func TestAnUnusedSetIsRemovedWhole(t *testing.T) {
dir := t.TempDir()
a := agentWithCache(t, dir)
set := weights.CachePathFor(dir, "https://mirror/coder/v1")
if err := os.MkdirAll(set, 0o755); err != nil {
t.Fatal(err)
}
write(t, filepath.Join(set, "model-00001-of-00002.gguf"))
write(t, filepath.Join(set, ".complete"))
// A link in the cache is taken away, not what it points at.
target := t.TempDir()
write(t, filepath.Join(target, "keep.gguf"))
link := weights.CachePathFor(dir, "https://mirror/linked/v1")
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
a.removeWeights([]string{set, link})
if _, err := os.Stat(set); !os.IsNotExist(err) {
t.Errorf("the set is still there: %v", err)
}
if _, err := os.Lstat(link); !os.IsNotExist(err) {
t.Errorf("the link is still there: %v", err)
}
if _, err := os.Stat(filepath.Join(target, "keep.gguf")); err != nil {
t.Errorf("what the link pointed at was deleted: %v", err)
}
}
+109
View File
@@ -0,0 +1,109 @@
package agent
import (
"context"
"crypto/sha256"
"os"
"path/filepath"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
// The cluster's settings for this agent (BACKLOG E3): what agent.yaml can
// say but the agent's id, control planes, address and certificate, kept by
// the control planes and sent over the stream. Kept here beside agent.yaml,
// which then holds only what the agent needs to start; at start the two are
// read together, these over the file.
// SettingsFile keeps the settings the control plane last sent.
var SettingsFile = "/var/lib/llmgw-agent/agent-settings.yaml"
// ReadSettings is what SettingsFile holds, nil when nothing.
func ReadSettings() []byte {
b, err := os.ReadFile(SettingsFile)
if err != nil {
return nil
}
return b
}
// SetSettingsCheck says how to tell whether settings would load beside this
// agent's own file; settings that would not are not taken.
func (a *Agent) SetSettingsCheck(f func([]byte) error) { a.settingsCheck = f }
// applySettings takes the settings the control plane sent: nothing when they
// are what this agent runs on, and otherwise kept and started again on.
func (a *Agent) applySettings(m *llmgwv1.AgentSettings) {
next := m.GetYaml()
if next == a.cfg.Settings {
return
}
if a.settingsCheck != nil {
if err := a.settingsCheck([]byte(next)); err != nil {
// Kept running on what it has: settings that will not load
// here must not take the node out of service.
a.log.Error("the cluster's settings for this agent do not load beside its agent.yaml; running on what it has",
"err", err)
return
}
}
if err := os.MkdirAll(filepath.Dir(SettingsFile), 0o755); err != nil {
a.log.Error("could not keep the cluster's settings for this agent", "err", err)
return
}
tmp := SettingsFile + ".new"
if err := os.WriteFile(tmp, []byte(next), 0o600); err != nil {
a.log.Error("could not keep the cluster's settings for this agent", "err", err)
return
}
if err := os.Rename(tmp, SettingsFile); err != nil {
a.log.Error("could not keep the cluster's settings for this agent", "err", err)
return
}
a.log.Info("the cluster's settings for this agent changed; starting again on them, the engines keep serving")
time.Sleep(300 * time.Millisecond)
exitForUpgrade()
}
// WatchFile starts the agent again when its configuration file changes and
// still loads: agent.yaml is read only at start, and a change to it that
// waited for somebody to restart the agent was a change nobody saw take
// effect.
func (a *Agent) WatchFile(ctx context.Context, path string, loads func() error) {
if path == "" {
return
}
sum := func() [32]byte {
b, _ := os.ReadFile(path) //nolint:gosec // the agent's own configuration
return sha256.Sum256(b)
}
was := sum()
t := time.NewTicker(watchEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
now := sum()
if now == was {
continue
}
if err := loads(); err != nil {
// Once per change: an editor saving halfway must not stop the
// agent, and a broken file is reported, not restarted into.
a.log.Error("agent.yaml changed and does not load; running on what it had", "path", path, "err", err)
was = now
continue
}
a.log.Info("agent.yaml changed; starting again on it, the engines keep serving", "path", path)
time.Sleep(300 * time.Millisecond)
exitForUpgrade()
return
}
}
// watchEvery is how often agent.yaml is looked at.
var watchEvery = 10 * time.Second
Loaded 100 of 837 files, more files were not shown because too many files have changed in this diff. Show more