Compare commits

...
616 Commits
Author SHA1 Message Date
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
CI / test (push) Successful in 12m33s
Release / notes (push) Successful in 4s
Release / build (push) Successful in 6m32s
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
Release / build (push) Successful in 5m43s
CI / test (push) Successful in 11m44s
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 / notes (push) Successful in 4s
CI / test (push) Successful in 9m18s
Release / build (push) Successful in 4m44s
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
CI / test (push) Successful in 6m24s
Release / notes (push) Successful in 3s
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
Release / notes (push) Successful in 3s
CI / test (push) Successful in 9m12s
Release / build (push) Successful in 4m20s
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 / notes (push) Successful in 4s
CI / test (push) Successful in 10m30s
Release / build (push) Successful in 5m33s
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 / notes (push) Successful in 4s
Release / build (push) Successful in 17m32s
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
Release / build (push) Successful in 5m42s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 11m27s
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
Release / notes (push) Successful in 4s
CI / test (push) Successful in 9m28s
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
CI / test (push) Failing after 6m50s
Release / notes (push) Successful in 5s
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 / notes (push) Successful in 4s
CI / test (push) Successful in 8m44s
Release / build (push) Successful in 2m3s
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
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
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
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
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
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
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
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
Ilker ManapandClaude Opus 5.5 3558dba485 v0.11.4
The OpenAPI document carries serve's tools field.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 12:29:05 +02:00
Ilker ManapandClaude Opus 5.5 481523ca46 serve: tool calls declared, slots that hold the context, longer answers
A --context-length 32768 --ctx-pool 32768 model got llama.cpp's default four
slots of 8192, and refused what the gateway admitted; --parallel is now
what the pool holds of the context. max_output was 1024 for every model,
which cut an editor's agent off mid-file; it is now a quarter of the
context, from 1024 to 8192. --tools writes capabilities.tools, which the
Continue configuration now reads, as it reads max_output.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 09:30:40 +02:00
Ilker ManapandClaude Opus 5.5 df6d243ee7 The Continue configuration says what the catalog says of the model
It told Continue every model uses tools and nothing of its context: a model
whose calls come back as text stalled Continue's agent, and Continue sent
prompts far over a CPU replica's 4096 tokens, each refused. tool_use is now
written for a model whose catalog entry says tools, and the context length
with a fitting maxTokens whenever the catalog knows it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 09:16:34 +02:00
Ilker ManapandClaude Opus 5.5 ef33e52d3d ctl upgrade upgrades the leader last, wherever it is run
Run on the standby, it upgraded the leader first, then itself, and the
leadership moved twice.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 06:45:56 +02:00
Ilker ManapandClaude Opus 5.5 abaf086fd5 A standby's portal is the leader's; the changelog as the release notes read it
A proxy left pointing at a standby showed an empty cluster: the portal and
the admin pages read the standby's own memory. A standby now passes its
client port to the leader's, all but /health, /ready and /leader, which
must describe the machine a load balancer is choosing; and its admin port
passes the dashboard as well as the API. The leader trusts the control
planes' X-Forwarded-For, so clients keep their own address.

The changelog's candidate section is "Unreleased" again: release notes
for an rc read that section, and v0.11.3-rc.1 got none.

The e2e wait for two endpoints is 30s: under the race detector on a busy
runner, 15s was missed once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 06:34:57 +02:00
Ilker ManapandClaude Opus 5.5 c5b34978f5 Guardrails in the console
The console never mentioned them. A Guardrails view shows each policy with
its mode, fail behaviour and checks, whether its judge models have replicas
ready, where policies are attached (attach and detach from it), and what
they did in a window. The admin API gains GET /api/v1/guard/events, and GET
/api/v1/guard says what each policy does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 03:36:17 +02:00
Ilker ManapandClaude Opus 5.5 77fb48ba32 The probe reads the floating address from its file each time
The change to read it each time left the constructor storing the address
itself, so the file read each time was one named 10.0.0.161.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 02:56:23 +02:00
Ilker ManapandClaude Opus 5.5 86cfb82f42 Merge features-ha: two control planes, a fast failover, ctl upgrade in waves
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 02:44:55 +02:00
Ilker ManapandClaude Opus 5.5 c5e388807a ctl upgrade in waves, and the other control plane first
One node at a time was hours on a fleet of hundreds. Nodes now go one, then
two, four, up to --parallel at once, each wave waited on whole; a wave that
does not come back stops it. The other control plane is upgraded first over
ssh, so a leader restarted last hands over to a standby on the release.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 02:43:33 +02:00
Ilker ManapandClaude Opus 5.5 964b659849 A stopping control plane gives up the lease before it lets its agents go
With the agent server closed rather than drained, the process could exit
before Run's release had reached the store, and the standby waited out the
lease. The lease is now given up first, synchronously, and nothing takes it
again after.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 02:31:13 +02:00
Ilker ManapandClaude Opus 5.5 c2b4985abe The floating address is read each time the summary is asked
server add records it after restarting the new control plane, which then
never named it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 02:29:00 +02:00
Ilker ManapandClaude Opus 5.5 1d1409faad A standby's admin API is the leader's
The console and ctl on a standby showed its own empty view: nodes and
replicas live in the leader's memory. A standby now passes /api/ to the
leader over the agent port, with the cluster's certificates both ways; the
leader answers only a certificate naming a control plane the store lists,
and only while it leads. Control plane certificates are issued for client
use as well, and server add reissues older ones.

An agent's reconnect backoff starts again after a session that worked, so
each failover it lives through no longer makes the next one slower.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 02:27:11 +02:00
Ilker ManapandClaude Opus 5.5 adef25d709 server add records the floating address where the summary can read it
keepalived's file holds its password and is root's alone; the control plane
runs as llmgw and could not read it, so the CONTROL line never named the
floating address.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 02:19:48 +02:00
Ilker ManapandClaude Opus 5.5 ec1918b23b Two control planes: a failover in seconds, and the console to set one up
A control plane being stopped held every agent's stream for the thirty
seconds of a graceful shutdown, so the standby led within four seconds and
had no nodes for thirty. The agent server is now closed, not drained. A
control plane that loses its lease while it lives on lets its agents go,
rather than keeping nodes the new leader cannot reach.

ctl status and the console's Overview say which control plane leads, which
stand by, and whether the floating address is on the leader. The console
adds a control plane (Do → Add a control plane), asking for the machine and
the floating address. server add says when the mirror would refuse the new
control plane.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 02:17:51 +02:00
Ilker ManapandClaude Opus 5.5 289a4ff8e8 Merge features-c2: section C of the backlog, items 6-24, for v0.11.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:30:38 +02:00
Ilker ManapandClaude Opus 5.5 2158a933e0 v0.11.2
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:30:37 +02:00
Ilker ManapandClaude Opus 5.5 803d1d5fb6 CHANGELOG and BACKLOG for the rest of C24 and the engine fixes; README
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:30:01 +02:00
Ilker ManapandClaude Opus 5.5 33334c8f33 Merge C24 federation: another llmgw cluster as a provider
Conflicts with facility-aware placement (config, control, admin API routes,
store test tables) all keep both; the generated reference and OpenAPI
document regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:29:35 +02:00
Ilker ManapandClaude Opus 5.5 f1a3199e55 KV routing tests wait for both replicas to be scraped: a replica not yet heard from is passed over, and the test was racing that
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:27:14 +02:00
Ilker ManapandClaude Opus 5.5 9e46ee83e4 Merge C24 KV-index routing from vLLM's cache events
Conflicts with the LoRA work: vLLM's arguments, the engine profile, router
metrics and the scheduler's request all keep both; agent.pb.go regenerated
from the merged proto; the two test fakes of vLLM given separate names.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:24:25 +02:00
Ilker ManapandClaude Opus 5.5 40e9fd2ab1 Federation: another llmgw cluster as a provider, its models as PEER/MODEL
ctl federation add/list/remove/sync keeps peers in the store (migration
0029); their /v1/models are offered here as external models named
PEER/MODEL and kept in step, a model still named by a fallback kept
withdrawn. Peers are checked at /ready, timed, and taken out of routing
while down. Requests to a peer carry x-llmgw-hop and are refused (508) when
they come back to a cluster they passed through; residency and no_store
keep requests off peers; X-Request-Id and, with --send-tags, attribution
tags go along. ctl model fallbacks sets overflow to a peer. Console view,
metrics, admin API routes and docs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:21:30 +02:00
Ilker ManapandClaude Opus 5.5 117f417ef5 Precise KV-index routing: vLLM KV cache events through the agent, routed by leading blocks held
Plan §7.5 tier 2, opt-in per profile with kv_events. The agent starts vLLM
publishing KV cache events to a loopback port it listens on (pure-Go zmq4,
msgpack in both of vLLM's encodings), with sha256_cbor hashing and a fixed
PYTHONHASHSEED; it mirrors each engine's blocks and sends net changes up the
existing stream, with a full copy on every new one and a reset on gaps,
restarts and clears. The control plane hands them to the router, for the
sending node's own replicas only. The router tokenizes through a replica's
/tokenize, hashes the tokens as vLLM does (checked against cbor2/hashlib
golden values), and scores replicas by the share of leading blocks held,
falling back to the approximate tree. New metrics beside
llmgw_prefix_decisions_total. Untested against a running vLLM.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:21:11 +02:00
Ilker ManapandClaude Opus 5.5 3ec90ded72 Merge C24 facility-aware placement
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:18:20 +02:00
Ilker ManapandClaude Opus 5.5 0d3eb82b9b Facility-aware placement: racks, PDUs, holds and power caps
Nodes say where they are with llmgw.io/site, room, row, rack and pdu labels,
from agent.yaml or `llmgw ctl label` (kept in the store, over the agent's).
Placement spreads a model's replicas across racks and PDUs, keeps a
multi-node replica within one domain when its profile says keep_within,
places nothing in a domain held with `llmgw ctl facility hold` (optionally
draining it), and refuses a shard that would take a domain past its power
cap from facility.budgets, counting declared, derived and measured draw.
Explain-placement names the domain, and keeps the rejections of a placed
replica; `ctl nodes` gains LOCATION and --domain; `ctl facility` lists the
domains; the console's Nodes view shows and sets location and holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:17:49 +02:00
Ilker ManapandClaude Opus 5.5 bd3eca4092 Merge C24 LoRA adapters on demand for vLLM, with affinity
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:15:12 +02:00
Ilker ManapandClaude Opus 5.5 2a1e0e6c4c LoRA adapters on vLLM, loaded on demand with replica affinity: max_loaded_adapters
A vLLM model can carry PEFT adapters, registered at start, or with
max_loaded_adapters held at most N per replica: the router loads an adapter
through vLLM's runtime LoRA API when a request first wants it, sends the
adapter's requests to a replica that holds it while that one is not
saturated, loads each cold adapter once, and unloads the least recently used
idle one to make room. llama.cpp keeps loading every adapter at start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 01:14:38 +02:00
Ilker ManapandClaude Opus 5.5 cc4de36674 Merge engine fixes: SGLang metrics and flags, bench reading guarded metrics
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:20:49 +02:00
Ilker ManapandClaude Opus 5.5 0416b827fd ctl bench reads a guarded engine's metrics with the engine's key, or says why it cannot
The sampler sent the key given for --target, which for the gateway is a
client's, so llama.cpp and TensorRT-LLM refused every read and the run
reported an idle engine. The engine key now comes from the admin API for the
replica at --metrics' address, or --metrics-key; unreadable metrics stop the
run before any load, and a step that read none shows why.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:20:25 +02:00
Ilker ManapandClaude Opus 5.5 8cc8d7139b SGLang read in its own metric names, started serving them, and asked for its own help
The router read SGLang replicas with vLLM's names, and SGLang was started
without --enable-metrics, so it served no /metrics: every scrape failed as
if unreachable and the replica was routed to blind. It now gets
--enable-metrics and a mapping of its own (sglang:num_running_reqs,
num_queue_reqs, token_usage, the token counters and num_requests_total, as
0.5.21 publishes them). engine-check asks python -m sglang.launch_server
--help instead of Python's own help, and SGLang is given --tp-size rather
than the --tp argparse only takes as an abbreviation.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:20:25 +02:00
Ilker ManapandClaude Opus 5.5 6689d721a9 BACKLOG: the font note is done
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:18:18 +02:00
Ilker ManapandClaude Opus 5.5 9f5a4bf11f The about page's fonts carry Turkish letters: latin-ext subsets beside the latin ones
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:18:08 +02:00
Ilker ManapandClaude Opus 5.5 a13a519386 CHANGELOG and BACKLOG for C17-C23 and the first parts of C24; README lists the new docs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:17:16 +02:00
Ilker ManapandClaude Opus 5.5 88d63fcb76 Merge C24 portal in English and Turkish
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:16:03 +02:00
Ilker ManapandClaude Opus 5.5 6f85c17fc5 Merge C18 offline bundles and the Helm chart
Conflicts: server.go imports (both kept); agent.pb.go regenerated with buf
from the merged proto.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:15:47 +02:00
Ilker ManapandClaude Opus 5.5 e8a62b23b7 Merge features-c2: the docs portal and its links in English and Turkish
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:15:25 +02:00
Ilker ManapandClaude Opus 5.5 bae16426a2 Offline sites and Kubernetes: llmgw bundle create, import and trust, offline.mirror, server init --bundle; a Helm chart for the control plane
A bundle carries what a cluster would download — llmgw's release with its
signed checksums, every llama.cpp build per architecture with GitHub's
checksums, model files pinned and checked against Hugging Face's — into a
directory or a tar whose manifest sha256 is carried apart. Import checks
everything, the release against the signing key, before anything is put in
the mirror. With offline.mirror set, nodes install llama.cpp from the
mirror against checksums the control plane holds and sends over the agent
stream; releases default to the mirror for node add, ctl upgrade and agent
upgrades. server init and the setup form install from a bundle.

The Helm chart runs one control plane with SQLite on a claim or an external
PostgreSQL, Secrets for the agent certificate and admin token, Services,
Ingress and ServiceMonitor; deploy/container/Dockerfile builds its image.
GPU nodes stay outside Kubernetes, as docs/kubernetes.md explains.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:13:39 +02:00
Ilker ManapandClaude Opus 5.5 11231d8c2b The portal in English and Turkish: a catalogue per language, a switcher at the foot of every page, landing.language, Turkish numbers and dates
Every template and handler message in internal/web goes through a JSON
catalogue under internal/web/locales/<code>/, with English as the fallback.
The language is the person's choice (a cookie set by /_lang/{code}), else
Accept-Language, else landing.language. Tests refuse a key some language
lacks, a translation with other arguments than English, and template text
left in English.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:12:53 +02:00
Ilker ManapandClaude Opus 5.5 d41104a452 Merge C21 quality suites and gating
Conflicts: docs.go (manual, OpenAPI and suites all kept), modelserve Job
(trace and eval both kept). The eval routes are added to the OpenAPI route
table, update and promote documented with their optional body, and the
document regenerated.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:08:33 +02:00
Ilker ManapandClaude Opus 5.5 9dc82f73d4 Merge C19 OpenAPI for the admin API and a Terraform provider
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:06:34 +02:00
Ilker ManapandClaude Opus 5.5 801e2d51c0 Merge C20 tracing beyond the router
Conflicts: config imports and docgen sources (both kept); agent.pb.go
regenerated with buf from the merged proto (MIG devices and trace context).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:05:29 +02:00
Ilker ManapandClaude Opus 5.5 f0477f140d Quality suites, and new weights held to one: ctl eval, model update/promote --eval, model gate
A suite is a set of cases in YAML or JSON lines, each scored by exact,
contains, regex, a number within a tolerance, a JSON Schema subset, or a
judge model with a rubric; weighed into one score against a threshold.
internal/bench/eval runs one on the control plane through Router.Replay, as
the gateway's own requests, and keeps every run and its cases in the store
(migration 0028: eval_runs, eval_cases).

- llmgw ctl eval MODEL --suite FILE|NAME, --history, --run, --suites; exits
  non-zero below the score. Suites by name from control.eval_dir, or the
  two built in from deploy/eval (arithmetic, json-format).
- model update and promote take --eval SUITE --min-score N, or a model's
  standing gate (ctl model gate, eval: in the catalog): a promote asks the
  canary first and leaves it below the score; an update tries the new
  weights as a canary given no share, then promotes or removes it.
- The console's model menu: ask a suite with its progress, its runs, its
  gate.
- The audit middleware no longer hands a handler a body cut off at 64 KiB.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:04:43 +02:00
Ilker ManapandClaude Opus 5.5 11b5d44702 A Terraform provider for llmgw: models, quotas and guard attachments through the admin API
terraform-provider-llmgw/ is a module of its own, built on
terraform-plugin-framework, so the gateway does not carry its dependencies.
llmgw_model is a model added at runtime: scaling, class, owner and adapters
change in place, anything else replaces it; the definition is kept as
written rather than as the catalog filled it in. llmgw_quota and
llmgw_guard_attachment keep the scope as written beside the ID it resolved
to, and import by that ID. Data sources llmgw_models and llmgw_nodes.

The client lists every endpoint it calls and refuses any other; a test
checks each, and every field it sends and reads, against
docs/admin-api.openapi.json, so a change to the admin API that breaks the
provider fails here. Acceptance tests run Terraform against an admin API in
memory when TF_ACC is set. CI vets and tests the module.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:04:35 +02:00
Ilker ManapandClaude Opus 5.5 e725adc2f0 Tracing beyond the router: reconcile passes, placement and a replica's start traced down to the node
A reconcile pass that does something is a trace, with placement (where it
went, or every candidate turned down), scale-up/down, drain, stop, eviction,
preemption, reaping and restarts beneath it. A replica's start is a span open
from placement until it serves; the start command carries its context in a
new ControlMessage.trace field, and the agent continues the trace through
the weights fetch, engine launch, rendezvous and readiness wait. Stops,
drains, adoption and engine installs are traced on the node, model jobs are
a trace with a span per step linked to the replica's start, and agent.yaml
takes the same tracing block as server.yaml. Off, every span is the no-op one
and commands carry nothing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:03:17 +02:00
Ilker ManapandClaude Opus 5.5 c64af74085 Merge C23 TensorRT-LLM engine and Ollama/GGUF import
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:02:50 +02:00
Ilker ManapandClaude Opus 5.5 7940e8593c TensorRT-LLM as an engine, and models from Ollama into the mirror
Engine trtllm runs trtllm-serve on one node, through a launcher of
llmgw's that demands the replica's key and turns on the Prometheus
endpoint its queue and cache gauges are read from; the router reads it
at /prometheus/metrics. Brought by the operator (engines.trtllm.python),
not installed; checked by ctl engine-check trtllm. Written against the
documented CLI, server and metrics and tested with stand-ins: not yet
run on a datacenter GPU.

mirror import-ollama copies a model's GGUF, and a vision model's
projector, from Ollama's directory into the mirror, checked against the
digest Ollama named each blob by; mirror import does the same for any
local file. The console's Mirror view imports from Ollama with I.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:02:01 +02:00
Ilker ManapandClaude Opus 5.5 330a421118 Merge C22 MIG
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:01:51 +02:00
Ilker ManapandClaude Opus 5.5 0fd55a862b MIG: a card in MIG mode is placed as its instances, each a GPU of class <card>-mig-<profile>
Discovery reports a card in MIG mode as its instances instead of the whole
card, on both paths: nvidia-smi (-L names them, -q -x gives their memory,
ids and processes) and NVML (GetMigMode, GetMaxMigDeviceCount,
GetMigDeviceHandleByIndex, behind an interface so it is tested with a fake).
Each instance has its MIG UUID, its own memory, and a class from the card's
and the profile: a100-80g-mig-3g.40gb. Placement numbers instances
1000 + 100 x card + instance and every view shows them as card/migN.

A replica on MIG runs on exactly one instance: a process opens one MIG
device and instances have no NVLink between them, so tp, pp, gpus or nodes
above one on a MIG class are refused in catalog validation and again in
placement, and the planner offers one instance or none. The agent gives
the engine CUDA_VISIBLE_DEVICES=MIG-<uuid> (no Vulkan device variable on
MIG). Memory and processes are per instance; heat, power and wear the
card's; utilisation is left out rather than reported as zero. A card in MIG
mode with nothing carved stays out of placement with the reason, and
llmgw doctor reports each card's mode and instances and warns of the states
in which nothing can run. ctl nodes, ctl status, the console and the plan
count devices by class. docs/install.md has a MIG section.

Written against fakes of nvidia-smi and NVML output; not yet run on MIG
hardware.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-05 00:01:14 +02:00
Ilker ManapandClaude Opus 5.5 f48f55a866 Merge C17 response cache
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:51:19 +02:00
Ilker ManapandClaude Opus 5.5 44f3a9e31d Reusing answers: a response cache per project, exact match, opt-in with a TTL
llmgw keys cache --project P --ttl 1h [--any] | --off, and Cache answers… on
the console's project menu. The same request — project, endpoint, canonical
model and the body without stream, stream_options, user, metadata, store and
service_tier, keys sorted — is answered from the gateway's memory for the
TTL. Only reproducible answers (temperature 0 or a seed; embeddings, rerank,
score) unless --any; only a whole 200 from the model asked for, not a
fallback's, a canary's, or one a guardrail changed.

Looked up after the limits and the input guardrails; the output policy
judges a reused answer; no_store and stored conversations bypass it.
Streamed answers are kept put back together and streamed back as chunks.
x-llmgw-cache: hit|miss|bypass, Age on a hit, Cache-Control: no-cache or
x-llmgw-cache: no to bypass. A hit is recorded with no tokens and no cost,
marked cache_hit (migration 0027) and shown by ctl request. An LRU bounded
by router.cache.max_bytes and max_entry_bytes; llmgw_cache_requests_total,
llmgw_cache_bytes, llmgw_cache_entries.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:50:53 +02:00
Ilker ManapandClaude Opus 5.5 a95c519bc5 The admin API described in OpenAPI 3.1: generated from a route table by docgen, served at /api/v1/openapi.json
adminapi.Routes lists every endpoint with its query parameters and Go values
of the bodies it reads and answers with; internal/docgen/openapi turns that
into docs/admin-api.openapi.json by reflection, the way encoding/json encodes
the types, with the fields' comments as descriptions. A test fails when an
endpoint is registered and not in the table, another when the committed
document is stale. The binary embeds it and serves it with the token.

Handlers that wrote map literals and decoded anonymous structs now use named
types, so the document can see them. GET /api/v1/models/{name} answers with a
model's whole definition as it is in force, which a tool managing it needs.
docs/admin-api.md: reaching the API, the token, what a request must carry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:47:59 +02:00
Ilker ManapandClaude Opus 5.5 e81f1cba4c Merge C24 docs portal and client guide
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:47:35 +02:00
Ilker ManapandClaude Opus 5.5 bd439aca29 A docs portal at /docs and a client developer's guide
The manual the binary carries, rendered on the client port: a sidebar of
the documents in the console's reading order (now one list, llmgw.Manual,
for both), heading anchors, links between documents kept in the portal and
links to files outside the binary sent to the repository, no script.
Public by default; landing.docs_sign_in sends visitors to sign in first.

docs/clients.md shows the official OpenAI and Anthropic SDKs against
llmgw in Python, TypeScript, Go and curl, with llmgw's headers; the
portal's index, the landing page and the account page link to it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:46:42 +02:00
Ilker ManapandClaude Opus 5.5 30f7f3474a Forgetting a person and keeping requests in the cluster: users forget, keys residency --in-cluster
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:27:31 +02:00
Ilker ManapandClaude Opus 5.5 d67bd9b2cc A test against a real Vault, when one is named
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:22:36 +02:00
Ilker ManapandClaude Opus 5.5 35657c1417 Secrets in Vault: env: and vault: references wherever a secret file is named, and the store's own secrets sealed under a master key — Vault transit or a local key
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:21:44 +02:00
Ilker ManapandClaude Opus 5.5 bce54f0941 Scaling by the clock: schedule windows with their own min and max, prewarm, control.timezone
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:16:56 +02:00
Ilker ManapandClaude Opus 5.5 74429f1c00 SLO fields described
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:13:30 +02:00
Ilker ManapandClaude Opus 5.5 5b92ae0277 Token-level timing and admission by SLO: TPOT, inter-token and tokens a second by model and tenant; a model past its SLO is saturated for the lower bands
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:13:18 +02:00
Ilker ManapandClaude Opus 5.5 10466416df server restore: an archive checked, dry-run, and put back — configuration with modes and owners, the store in SQLite or PostgreSQL; backup_copy_to copies archives off the machine
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:09:59 +02:00
Ilker ManapandClaude Opus 5.5 d29f694aaf Moderations name a judge's categories once, by name
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 23:03:35 +02:00
Ilker ManapandClaude Opus 5.5 e502996198 Anthropic's Messages API: /v1/messages translated to chat completions and back, streamed or not, with tools and images; keys in x-api-key
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 22:59:55 +02:00
Ilker ManapandClaude Opus 5.5 9136bc71c2 More of OpenAI's API: Responses structured output, images and previous_response_id; rerank, score and audio passed through; moderations by the judge
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 22:56:25 +02:00
Ilker ManapandClaude Opus 5.5 32ff1dbd6f Requests kept and replayed: keys requests per project with retention and a share, ctl request --body and --replay
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 22:51:21 +02:00
Ilker ManapandClaude Opus 5.5 841ee21c12 Batches: OpenAI's Files and Batch APIs, served at the lowest priority under a store lease, each line a request of the key that made it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 21:00:01 +02:00
Ilker ManapandClaude Opus 5.5 889be70b8f Canary on the console's model menu; docs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 20:33:59 +02:00
Ilker ManapandClaude Opus 5.5 a1dfd4df1d Canary of a model's newer weights: ctl model canary, promote and canaries, rolled back by itself when it serves worse
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 20:31:26 +02:00
Ilker ManapandClaude Opus 5.5 0a4076928a Canary routing: a model's canary takes its share of requests, a session stays on one side, x-llmgw-variant pins one, and the two are compared
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 19:56:16 +02:00
Ilker ManapandClaude Opus 5.5 29a7347df2 Requests can be tagged: metadata, x-llmgw-tags and the user field on the usage record, and counted by the tags router.tag_labels names
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 19:45:37 +02:00
Ilker ManapandClaude Opus 5.5 e59ff52932 Merge features-c: section C of the backlog, items 1-5, and a hardened install
Audit log and staff roles, OIDC and SCIM, key lifecycle, usage export,
notifications; server init writes baseline guardrails with two Llama Guard
judges failing closed, and keys of at most 90 days.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 19:20:41 +02:00
Ilker ManapandClaude Opus 5.5 c553f2041a The baseline fails closed with two judges, and a judge not answering is a notification
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 19:19:49 +02:00
Ilker ManapandClaude Opus 5.5 07af03aa93 server init: the firewall and the judge before the starter model, and a model that fits nowhere said within two minutes
A starter model with no room failed init after ten minutes and left the
firewall and the judge uninstalled.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 19:11:12 +02:00
Ilker ManapandClaude Opus 5.5 ddb0472f9b A hardened install by default: baseline guardrails with Llama Guard 3 1B judging on a CPU, keys at most 90 days
server init (and the setup screens) write guard.default [baseline] —
secrets refused, cards masked, injection recorded, the judge refusing
S1-S4, S7, S9-S12 in prompts and answers, fail open after 5s — fetch the
judge into the mirror and run it on a node's CPU. --no-guard-model leaves
it out. The closing text names what init cannot do: TLS on the client
port and backup recipients.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 17:45:31 +02:00
Ilker ManapandClaude Opus 5.5 6abd510628 Docs: one way in — quick-install starts at the setup screens, quickstart becomes development, install.md no longer says engines are yours to install
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 17:32:54 +02:00
Ilker ManapandClaude Opus 5.5 4979e08d64 Console: rotate a key, cap key lifetimes, test the webhooks; README, onboarding and console docs caught up
The README's status stopped at v0.9 and listed gaps since closed; onboarding
still said there was no SSO and no roles.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 17:28:24 +02:00
Ilker ManapandClaude Opus 5.5 1b71c38f89 Notifications: webhooks for a parked model, a lost node, a spent budget and new weights; Alertmanager receiver templates
notify.webhooks sends signed JSON or Slack-compatible messages, queued,
retried, de-duplicated hourly; ctl notify test checks each webhook.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:58:11 +02:00
Ilker ManapandClaude Opus 5.5 646ff996c7 gofmt the usage export
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:49:14 +02:00
Ilker ManapandClaude Opus 5.5 57739d736a Usage export as CSV: by period and by place, person, key and model, from the rollups
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:49:04 +02:00
Ilker ManapandClaude Opus 5.5 15a7237a02 Match a mapped project however its path is spaced: the store writes "Acme / ml / train"
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:45:47 +02:00
Ilker ManapandClaude Opus 5.5 8866a1ea0f Key lifecycle: lifetime limits by scope, rotation with overlap, kinds and expiry in the listing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:45:05 +02:00
Ilker ManapandClaude Opus 5.5 178964cf6a gofmt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:40:27 +02:00
Ilker ManapandClaude Opus 5.5 14ea59d83f Provisioning through SCIM 2.0: accounts and groups kept in step by the identity provider
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:40:15 +02:00
Ilker ManapandClaude Opus 5.5 5cff456b9b Let /login/oidc through without an API key: /login alone was exempt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:37:24 +02:00
Ilker ManapandClaude Opus 5.5 dfe4093aea gofmt the OIDC pages
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:36:20 +02:00
Ilker ManapandClaude Opus 5.5 4cf40644c9 Single sign-on through OpenID Connect: accounts, staff roles and memberships from the provider
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:36:15 +02:00
Ilker ManapandClaude Opus 5.5 b60cf45adf Staff roles (viewer, operator, billing) and the console's Audit view
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:30:56 +02:00
Ilker ManapandClaude Opus 5.5 f8fcecd640 Audit log: every change through the admin API and the administration pages, with ctl audit
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:25:56 +02:00
Ilker ManapandClaude Opus 5.5 bcd2413535 Merge guardrails-b: section B of the backlog (guardrails)
CI / test (push) Successful in 2m37s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 16:09:48 +02:00
Ilker ManapandClaude Opus 5.5 4df30fc175 Record section B's state after trying it on the PoC
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:40:18 +02:00
Ilker ManapandClaude Opus 5.5 bd4736125b Do not name a judge model in the error a client gets for a model nobody serves
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:37:52 +02:00
Ilker ManapandClaude Opus 5.5 dbcecd1e85 Judge an answer with Llama Guard 3's own prompt, as a completion, not a conversation ending on the answer
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:35:57 +02:00
Ilker ManapandClaude Opus 5.5 742d6633e2 Read a judge's verdict after the answer llama.cpp goes on from, and log a verdict that cannot be read
Found on the PoC: llama.cpp takes a conversation ending on the assistant's
turn as a reply to continue, so Llama Guard answered "Rome.safe" and every
answer judged was served unjudged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:33:23 +02:00
Ilker ManapandClaude Opus 5.5 c69e6f151c Serve a judge model in one command: ctl model serve --guard-format
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:27:35 +02:00
Ilker ManapandClaude Opus 5.5 62038484d6 Never lose a word held back at one release when the next has nothing to rewrite
Found on the PoC: a streamed list came out as "1. 2. 3." — each item cut at
one release and dropped at the next, which sent the engine's own events.
A property test now cuts an answer every way and checks it arrives whole.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:24:22 +02:00
Ilker ManapandClaude Opus 5.5 2cc52fd1a7 Read an unwritten guard mode as enforce when merging, not as the weakest
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:22:07 +02:00
Ilker ManapandClaude Opus 5.5 83e1444981 Regenerate the configuration reference with the judge and the guard role
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:18:51 +02:00
Ilker ManapandClaude Opus 5.5 d55472c00a Guardrails in the console: attach and detach on a branch, where each applies from, and a column
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:18:42 +02:00
Ilker ManapandClaude Opus 5.5 02aac84ca5 The /admin/guard page, a Guardrails link, and the policies in force on the profile page
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:15:38 +02:00
Ilker ManapandClaude Opus 5.5 7e18b8ab53 Show guardrail refusals and stopped answers on the chat page, and the verdict in ctl request
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:13:15 +02:00
Ilker ManapandClaude Opus 5.5 47e847423d Guardrail metrics and alerts: checks, latency, judge failures, fail-open, redactions, stopped streams, tenant blocks
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:11:50 +02:00
Ilker ManapandClaude Opus 5.5 43f6a585a6 A judge model on the cluster: role guard in the catalog, judge in a guard policy
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 15:09:31 +02:00
Ilker ManapandClaude Opus 5.5 0b4c4f0698 Put guard policies in the configuration reference
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:40:26 +02:00
Ilker ManapandClaude Opus 5.5 dc788e5cda Opt-in capability checks and structured-answer validation in guard policies
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:39:40 +02:00
Ilker ManapandClaude Opus 5.5 2f07b2ba0b Document guardrails, correct what data.md said leaves the cluster, and regenerate the reference
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:36:37 +02:00
Ilker ManapandClaude Opus 5.5 ec737cffb5 no_store: refuse conversations, notes and models outside the cluster under a policy that keeps nothing
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:36:02 +02:00
Ilker ManapandClaude Opus 5.5 13e6f19301 Attach guard policies to scopes: store, admin API and ctl guard; run the gate when server.yaml defines policies
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:34:47 +02:00
Ilker ManapandClaude Opus 5.5 b4bdd250af The gate: judge requests and answers by the policies in force, buffered or a sentence at a time
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:31:55 +02:00
Ilker ManapandClaude Opus 5.5 ccdc07fe0c Guard policies: a library in server.yaml, merged so nothing below loosens what is above
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:27:23 +02:00
Ilker ManapandClaude Opus 5.5 e3ef5b18c1 Test national phone numbers and bare digit runs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:25:32 +02:00
Ilker ManapandClaude Opus 5.5 ee33524599 Find personal data and secrets by validated pattern, and prompt injection through disguises
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:25:20 +02:00
Ilker ManapandClaude Opus 5.5 9b9349183b gofmt the guard hooks
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:23:02 +02:00
Ilker Manap 7716d9a631 Merge main 2026-10-04 12:22:52 +02:00
Ilker ManapandClaude Opus 5.5 24d717282b gofmt what the security work left unformatted
CI / test (push) Successful in 4m7s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:22:51 +02:00
Ilker ManapandClaude Opus 5.5 979aa44804 Hook a guard into the router: judge requests before dispatch and filter answers as they are relayed
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:20:24 +02:00
Ilker ManapandClaude Opus 5.5 e701f92806 Add the guard interface, and filter hooks in the proxy for buffered and streamed answers
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:16:00 +02:00
Ilker ManapandClaude Opus 5.5 379074b6a5 Merge security-a: section A of the backlog (security)
CI / test (push) Failing after 17s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:12:54 +02:00
Ilker ManapandClaude Opus 5.5 eb10aee93b Document TLS between the store's machines, and close section A of the backlog
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:12:35 +02:00
Ilker ManapandClaude Opus 5.5 43d3c12302 Make a member's own access rules hostssl when the cluster keeps them there
A cluster set up before the rules moved into the shared configuration kept
them in each member's config.yml, which the TLS step never looked at — and
then said its remote rules were hostssl. Found on the PoC: plaintext
connections were still accepted.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:11:05 +02:00
Ilker ManapandClaude Opus 5.5 191c65e3db Put etcd and Patroni's API on TLS, and move a running cluster there in maintenance mode
Members' etcd speaks TLS to peers and clients with a certificate from the
cluster's authority and asks clients for one; Patroni reaches it with the
member's certificate and serves its API over TLS. A running plain-HTTP
cluster is moved with Patroni paused, so PostgreSQL serves throughout:
Patroni's lease-free keys are carried over to the new etcd.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 12:07:17 +02:00
Ilker ManapandClaude Opus 5.5 fed8c75c6c Do not warn of a missing firewall during the node add that writes it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 11:58:18 +02:00
Ilker ManapandClaude Opus 5.5 855a34f0f7 Record the firewall as live on the PoC
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 11:52:28 +02:00
Ilker ManapandClaude Opus 5.5 efb4b06fc5 Report a ready engine dying from every connection, adopted ones included
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 11:50:09 +02:00
Ilker ManapandClaude Opus 5.5 bd155c18ac Give the secrets directory its mode when init hands it to root, so the service can still read the store's address
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 11:39:49 +02:00
Ilker ManapandClaude Opus 5.5 dae7afbc17 Record what of section A is done and what is left
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 11:05:10 +02:00
Ilker ManapandClaude Opus 5.5 7b6276901a Keep llmgw's internal ports to the cluster's networks with an nftables table of its own
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 11:03:46 +02:00
Ilker ManapandClaude Opus 5.5 7e57863edd Hide the mirror's bookkeeping, bound GGUF array nesting, and forget old serve jobs and downloads
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:56:16 +02:00
Ilker ManapandClaude Opus 5.5 fdd4dcf841 Serve every page under a hash-based Content-Security-Policy, and update KaTeX to 0.16.47
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:54:04 +02:00
Ilker ManapandClaude Opus 5.5 fd9f665ef8 End a session unused for a day, and purge dead sessions and claims hourly
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:50:56 +02:00
Ilker ManapandClaude Opus 5.5 ba5158d463 List in /v1/models only the models the key may use
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:49:54 +02:00
Ilker ManapandClaude Opus 5.5 f9493aadb9 Guard a GPU-less node's problems, and bound thread ids
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:49:10 +02:00
Ilker ManapandClaude Opus 5.5 a56ad8e4c4 Keep the control plane's fetches off link-local addresses, and see through NAT64, 6to4 and proxies
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:48:01 +02:00
Ilker ManapandClaude Opus 5.5 fbac1aa664 Take only torch from PyTorch's index, and lock its builds with every file's address
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:45:55 +02:00
Ilker ManapandClaude Opus 5.5 12efb7d085 Encrypt backups to backup_recipients with age
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:38:47 +02:00
Ilker ManapandClaude Opus 5.5 3b05ec0538 Have engines listen on the management address, and take SGLang's key off its command line
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:36:42 +02:00
Ilker ManapandClaude Opus 5.5 9b61bbc983 Revoke a removed node's certificate, and require --api-client with a mirror's mutual TLS
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:34:35 +02:00
Ilker ManapandClaude Opus 5.5 fe5b7f88f0 Believe X-Forwarded-* only from router.trusted_proxies
A new internal/clientip says who sent a request and how: the connection's
address, scheme and Host, or through a configured proxy what it forwarded.
The base URL in pages and setup scripts, isHTTPS for cookies, and the
per-address limits of auth and the sign-in forms use it. /setup scripts are
no-store.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:29:02 +02:00
Ilker ManapandClaude Opus 5.5 44cd58005a Count sign-in attempts per address and client, guard the sign-in form with a token, ration both forms per address
A wrong password from anywhere counted against the address, so ten locked
its owner out. Attempts are now counted per address and client, with a
looser bound per address alone. The sign-in form carries a token matched
by a cookie (login CSRF). POST /login and /signup are rationed per client
address, a hash waits at most ten seconds for a turn, and server init
writes signup_open: false.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:26:29 +02:00
Ilker ManapandClaude Opus 5.5 f1b49d0251 Bound idle time, header size and request reading on every listener, and connections on the client port
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:21:15 +02:00
Ilker ManapandClaude Opus 5.5 d3fb217b31 Bound the key cache and ration lookups of unknown keys per client address
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:19:38 +02:00
Ilker ManapandClaude Opus 5.5 ebf437a36a Have the setup's sudo grant remove itself within the hour, and keep a target from reading as an ssh option
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:17:52 +02:00
Ilker ManapandClaude Opus 5.5 1f406247e4 Expire join codes on the setup's side, bind the join port to its address, trust the peer address
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:16:53 +02:00
Ilker ManapandClaude Opus 5.5 27c49d0af9 Refuse the keys of an account that is not active
Resolve now checks the owner's state for a personal key; the authenticator
treats an inactive owner as a revoked key. Suspension stops the keys within
the cache TTL, reinstatement brings them back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:15:32 +02:00
Ilker ManapandClaude Opus 5.5 20ebd21243 Skip capacity fallbacks the key may not use, and charge what the model that answered costs
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:13:59 +02:00
Ilker ManapandClaude Opus 5.5 b201275fd9 Take agent_tls.insecure as consent to an agent port without TLS too
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:11:40 +02:00
Ilker ManapandClaude Opus 5.5 2b29016d5b Ask for a password on Patroni's API and give the store TLS, new clusters and existing ones in place
Patroni's REST API accepted changes from anyone on port 8008, and
PostgreSQL ran with sslmode=disable between machines. Setup now keeps a
fourth secret for the API (carried to members of older clusters), issues
each member a certificate from the cluster's authority, writes
restapi.authentication into each member's config and reloads it, and
turns ssl on with hostssl remote rules through an authenticated PATCH of
the shared configuration. The DSN becomes sslmode=verify-full against the
authority's certificate whenever the cluster has TLS, so a control plane
without the authority's key reconnects too. server add authenticates its
PATCH and lets the new control plane in by hostssl. Tried on the e2e
cluster: unauthenticated PATCH 401, plaintext refused, replication
streaming, the control plane on verify-full.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:11:28 +02:00
Ilker ManapandClaude Opus 5.5 9d961c3fc8 Say that the admin API wants its token, and that ctl runs with sudo
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:02:56 +02:00
Ilker ManapandClaude Opus 5.5 c0421bc7ee Guard the admin API and pprof with a token only root and the server can read
The admin listener answered any local process: models added, nodes
upgraded, engine keys listed, the heap dumped. /api and /debug now demand
the token the server writes 0600 to its state directory on first start;
ctl, the console and node add send it (sudo, LLMGW_ADMIN_TOKEN or
--token-file). pprof is off unless debug_pprof. admin_listen off loopback
is refused unless admin_remote. Dashboard pages, /metrics and /health are
unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 10:02:44 +02:00
Ilker ManapandClaude Opus 5.5 2d47427cdc Run engines as llmgw-engine rather than root, and make the user when a node is added
The supervisor drops to the run_as user (default llmgw-engine) when the
agent is root: its credential and groups, a 0700 home under the work
directory as the working directory, HOME and XDG_CACHE_HOME there. A node
without the user keeps engines as root and logs why. node add creates the
user in the video and render groups.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 09:57:41 +02:00
Ilker ManapandClaude Opus 5.5 c19abe95e1 Refuse profile env and args that would run code or touch files on the nodes
env is an allowlist of tuning families less path, plugin, library, address
and credential names (NCCL_*_PLUGIN, CUDA_INJECTION64_PATH and the like
got through the old denylist); llama.cpp's LLAMA_ARG_* are checked as
flags. args refuse --trust-remote-code, --log-file, --slot-save-path,
--tool-parser-plugin, --middleware, --allowed-local-media-path and others
that read, write or load, and what the agent sets itself. Checked in the
catalog and again on the node before an engine starts.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 09:54:45 +02:00
Ilker ManapandClaude Opus 5.5 c522e8b2aa Refuse to open the agent port without TLS unless agent_insecure says so
Without agent_tls the control plane served plaintext h2c on every
interface, and any machine reaching it could register as any node. The
server now refuses to start that way unless agent_insecure: true, and then
binds loopback when agent_listen names only a port.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 09:51:37 +02:00
Ilker ManapandClaude Opus 5.5 0bd5305e9a Install only releases signed with the key built in; fetch a node's release on the control plane
trusted.pub now holds the release signing key, so self-upgrade, agent
upgrade and node onboarding refuse a release whose checksums are not signed
with it. Onboarding fetched the release on the node and compared it with a
checksum from the same server; the control plane now fetches it for the
node's architecture with release.Fetch, checks signature and checksum, and
hands the bytes over. Release addresses must be https (loopback excepted).
LLMGW_RELEASE_KEY overrides the key only in development builds. The CI
release job fails instead of publishing an unsigned release while a key is
built in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 09:48:03 +02:00
Ilker ManapandClaude Opus 5.5 9461a7fc2d Say which Go builds it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 09:43:37 +02:00
Ilker ManapandClaude Opus 5.5 8e8b5568c2 Build with Go 1.26.6, and check for known vulnerabilities
govulncheck found eleven standard-library vulnerabilities reachable from
this code at Go 1.26.3 (net/http, crypto/x509, os.Root, net/textproto); the
go directive raised to 1.26.6 fixes every one. make vuln runs govulncheck;
make check runs it with the tests and vet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 09:43:27 +02:00
Ilker ManapandClaude Fable 5.1 e01107d525 Write down what the review of 2026-10-04 found to do: security first, then guardrails, then features
CI / test (push) Successful in 4m1s
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-04 09:38:01 +02:00
Ilker ManapandClaude Opus 5.5 c2238abf4e Merge chart-fill
CI / test (push) Successful in 4m9s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 08:55:19 +02:00
Ilker ManapandClaude Opus 5.5 0f70bdc99d Place chart points by their time, so a window's samples fill the chart's width
Five minutes at five-second steps is sixty samples on a chart a hundred and
thirty dots wide, and resampling by count drew them on its right half.
Points are now put in the column of their time, held across the columns
between samples, a series that began late starting where it began.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 08:55:19 +02:00
Ilker ManapandClaude Opus 5.5 e91f68a3bf Drop a local render helper committed by mistake
CI / test (push) Successful in 3m51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 03:39:20 +02:00
Ilker ManapandClaude Opus 5.5 2f9bc1ba23 Merge monitor-charts
CI / test (push) Successful in 3m59s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:51:05 +02:00
Ilker ManapandClaude Opus 5.5 a1b1663e9d A btop-like monitor in the console, from Prometheus; node exporters discovered and installed
Monitor draws CPU, memory, GPUs, requests, tokens, latency, network and disk
as Braille charts over 5m/1h/24h/7d, then per node, card and model
sparklines; node and model pages have their own. History is read from
monitoring.prometheus through POST /api/v1/metrics/range, or without it
from what the console saw. The control plane serves the cluster's machines
for Prometheus http_sd at /prometheus/targets on its metrics port, and node
add installs prometheus-node-exporter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:51:05 +02:00
Ilker ManapandClaude Opus 5.5 2e55487eca Merge background-jobs
CI / test (push) Successful in 3m59s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:27:02 +02:00
Ilker ManapandClaude Opus 5.5 e12996f7dd Serve and update models in the background from the console, and follow them under Jobs
The console starts a serve or update job over the admin API and stays,
instead of closing to follow the download. Jobs lists them with their
progress and steps; the sidebar counts those running. ctl model serve and
update take --detach; ctl jobs lists jobs and follows one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:27:02 +02:00
Ilker ManapandClaude Opus 5.5 d311a3ca4a Show the version top right on every screen
CI / test (push) Successful in 4m50s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:18:45 +02:00
Ilker ManapandClaude Opus 5.5 7cb9d4fa01 Busy is not unfit: rank files by the hardware the cluster has, and let a model wait for its cards
CI / test (push) Successful in 4m43s
Ranking what was free first made every file come out on the CPU on a cluster
whose one card was in use. The plan now ranks by hardware alone, free now
only breaking a tie; a model served onto cards that are busy is added and
waits for them, the job ending as waiting rather than failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:17:32 +02:00
Ilker ManapandClaude Opus 5.5 c2be429554 Tab or down from a form's Apply goes round to the first field instead of crashing the console
CI / test (push) Successful in 4m14s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:12:19 +02:00
Ilker ManapandClaude Opus 5.5 3b22ccce72 Merge fit-planner: plan a repository's files against the fleet, and serve the one that suits it
CI / test (push) Successful in 4m17s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:09:40 +02:00
Ilker ManapandClaude Opus 5.5 3b66784f22 Name each file by its quantisation first in the console's plan
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:09:05 +02:00
Ilker ManapandClaude Opus 5.5 0ea3008f64 On a CPU prefer a middling quantisation, on a card eight bits; past them a bigger file ranks lower
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:05:55 +02:00
Ilker ManapandClaude Opus 5.5 54db7aecb7 Plan a repository's files against the fleet, and serve the one that suits it the way it fits
The control plane reads the model's shape from one GGUF header or its
config.json and lists, for every file, the best way it runs here: one card,
several of a node's (by layer for llama.cpp, tensor-parallel for vLLM),
several nodes' over one fabric (vLLM, pipeline-parallel between them), or a
CPU node; what each card holds and whether it is free now. ctl model plan
prints it; model serve takes its file and way unless told, gives a GGUF on
GPUs a GPU profile, and with --fallbacks adds the next best ways. The
console shows it per file and asks for the way and the fallback.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 01:03:48 +02:00
Ilker ManapandClaude Opus 5.5 630d2788c0 Merge model updates, the manual, and models of several files
CI / test (push) Successful in 3m51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:43:15 +02:00
Ilker ManapandClaude Opus 5.5 a16097cfe8 Serve vision models with their projector and original weights on vLLM; group the mirror by version
model serve takes --projector, fetched beside the weights and loaded by
llama.cpp with --mmproj, and --format safetensors with --gpu-class and --tp,
which fetches the weights with their config and tokenizer and runs them on
vLLM. The console offers both, F switching between GGUF and original
weights. The mirror view is one row per version with its files on Enter.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:42:59 +02:00
Ilker ManapandClaude Opus 5.5 7aefb236d6 Publish llmgw_model_update_available, and alert on it
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:36:27 +02:00
Ilker ManapandClaude Opus 5.5 f36c46a881 Trace weights fetched before origins were kept, and let an origin be given by hand
The mirror keeps where it fetched a file beside it, and reports that and the
checksum after it restarts. A model whose files the mirror cannot trace is
found on the registry: a repository holding a file of that name that has the
commit the mirror's directory is named after, with every file at the same
size, the most downloaded first. ctl model origin and the console set one by
hand, checked the same way. Commits compare shortened.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:24:41 +02:00
Ilker ManapandClaude Opus 5.5 dac7ff0e99 The manual in the binary: llmgw docs, and Help in the console
Every document and a page for every command, built from their help, read
like info: topics, contents, find. ? opens the console's own page, which is
new and covers every screen.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:19:50 +02:00
Ilker ManapandClaude Opus 5.5 44425193bd Track the registry's new weights for served models, and move a model to them or back
A model served from the registry records its origin: repository, commit and
each file's checksum; one served before is worked out from the mirror's
record of where it fetched the file. Every mirror.update_check (6h) the
control plane asks whether those files changed there. ctl model updates
lists it, ctl model update fetches beside the old files, checks, swaps and
replaces replicas one at a time, ctl model rollback goes back. The console
shows it on Models and offers the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-04 00:12:20 +02:00
Ilker ManapandClaude Opus 5.5 a96b1a54b2 Hugging Face in the console: models that chat when browsing, ages read at a glance, long menus scroll
CI / test (push) Successful in 6m1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 23:47:26 +02:00
Ilker ManapandClaude Opus 5.5 2bf43ed90c Search Hugging Face from the console and serve what is found
CI / test (push) Successful in 5m3s
Serve a model is a view of the registry's GGUF models, trending or searched;
Enter on one lists its files, and the one chosen is served with
llmgw ctl model serve. A form of one field is applied by Enter on it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 23:44:51 +02:00
Ilker ManapandClaude Opus 5.5 6a7b4bb6a0 Merge tui-actions: everything settable on a running cluster, from the console
CI / test (push) Successful in 3m52s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 23:35:14 +02:00
Ilker ManapandClaude Opus 5.5 eb7dac8a67 The console manages people, keys, organizations and the mirror, and scrolls a tall sidebar
Accounts, API keys and Organizations views read the store and change it by
running the users, keys and org commands in-process, so their checks and
messages are the ones shown. A Mirror view lists what the mirror holds and
which models use it, downloads into it and removes from it. Models gain
adding from a definition file.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 23:30:24 +02:00
Ilker ManapandClaude Opus 5.5 c7d5ee16b5 Act on the console's rows: scale, class, owner, resources, adapters, restart, remove; cordon, drain, engines, upgrade; quotas; cluster tasks
Enter on a row of Models, Nodes or Replicas opens what can be done to it,
as forms and questions over the admin API. A Quotas view sets and clears
capacity quotas. Do gains serving a model, upgrading the cluster,
restarting every replica and a backup.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 23:23:12 +02:00
Ilker ManapandClaude Opus 5.5 e0384fb148 Release v0.11.1
Release / notes (push) Successful in 3s
CI / test (push) Successful in 4m0s
Release / build (push) Successful in 58s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 23:10:20 +02:00
Ilker ManapandClaude Opus 5.5 6e815ca21a Draw the console's overview in its panel, not over the screen
The summary's GPU, fabric and idle tables were written to standard output
regardless of the writer they were given, so on a cluster with a GPU the
console printed the GPU table across its full screen every three seconds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 23:09:40 +02:00
Ilker ManapandClaude Opus 5.5 36cdbce125 Merge ha: replicated store, second control plane, join codes, setup and console screens
CI / test (push) Successful in 5m14s
Release / build (push) Successful in 4m26s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 22:59:49 +02:00
Ilker ManapandClaude Opus 5.5 211688fe7c Merge usage-rollups: usage sums, per-tenant stats, alerts and dashboard
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 22:56:50 +02:00
Ilker ManapandClaude Opus 5.5 9ab93a0aa6 Watch the cluster and edit every setting from the console
The console gains live views of nodes, GPUs, models, replicas, usage and
agent logs, and a settings tree over every key of server.yaml (agent.yaml on
a node), generated from the configuration structs: values with their
defaults and doc comments, edited in dialogs, saved only once the service's
own loader accepts them, comments kept. The generator now tells blocks apart
by package, which brings the TLS, engine and supervisor settings back into
the reference.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 22:54:09 +02:00
Ilker ManapandClaude Opus 5.5 aa3efcebbe Add a standby control plane with server add, and draw the screens as windows
server add gives another machine this llmgw, a certificate, the
configuration and store address, lets it into the store through Patroni, and
starts it; --vip keeps a floating address on the leader with keepalived,
following a new /leader endpoint. Nodes learn every control plane from the
leader and notice a dead one by HTTP/2 ping instead of after the kernel's
fifteen minutes. The setup, console, node and init screens are drawn full
screen in one frame: title bar, sidebar, bordered panel, key bar, dialogs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 22:29:12 +02:00
Ilker ManapandClaude Opus 5.5 f5c79052b1 Let a machine join a setup by code, dialling out, with nothing logging in
The setup screens can reach a database machine by a join code instead of
ssh: they listen on 7444 and show a short code; llmgw join dials out,
checks the setup by fingerprint, admits itself with the code's secret, and
runs what the setup sends until it is done. A joined machine is one more
onboard.Remote, so checks and the Patroni install run over it unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 21:49:15 +02:00
Ilker ManapandClaude Opus 5.5 38f2671ed9 Open setup screens or a console when llmgw runs bare in a terminal
With nothing installed, sudo llmgw asks for one machine or three, takes how
to reach the other two (ssh agent, key or password, and a sudo password used
only for the install), checks every machine first, shows the plan and its
command line, and installs. On a control plane it opens a console with the
cluster's state and the next steps. Machines are reached with Go's ssh so a
password works; the store's etcd and access rules use this machine's routed
address rather than a client-facing name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 21:33:59 +02:00
Ilker ManapandClaude Opus 5.5 975c93994f Set up a replicated store with server init --patroni, and watch it
init --patroni user@host ... installs PostgreSQL, Patroni and etcd on the
given machines over ssh, with this one as the third etcd vote, makes the role
and database, and moves the store in use into the cluster. Each step checks
first, so it changes nothing on a cluster that is up. For a store of several
hosts the control plane publishes each member's role, state and lag from
Patroni, a stopped member as missing, prints it in ctl status, and alerts on
it. Making an administrator on PostgreSQL sent a bool to an integer column;
it sends an integer now.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 20:57:41 +02:00
Ilker ManapandClaude Opus 5.5 7a562ac1e7 Keep the store under Patroni, and back up from a store of several hosts
deploy/ha documents PostgreSQL under Patroni with a three-member etcd, as
running on the reference cluster, and the address that follows the primary.
Backups read such an address as libpq host and port lists and prefer a
standby, rather than resolving the list as one name.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 20:31:19 +02:00
Ilker ManapandClaude Opus 5.5 17cc2e379b Add a Grafana dashboard per tenant
Requests and tokens by tenant, the shares failing and refused by a limit,
first-token p95, budgets spent this period and the day's heaviest users,
filtered by a tenant chooser. Installed beside the cluster dashboard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:58:39 +02:00
Ilker ManapandClaude Opus 5.5 ce006a6e6f Publish per-tenant request and budget series, and alert on them
Requests by outcome, tokens and first-token time per tenant, at the level
router.tenant_label names (team by default), and every budget's share spent
this period. Rules alert on budgets at 80% and 100%, tenants hitting limits,
failing, spiking past their weekly average, or waiting on first tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:55:24 +02:00
Ilker ManapandClaude Opus 5.5 9f479b9a70 Show each team's first-token and latency percentiles, errors and refusals
Percentiles are read from the histograms kept with the usage sums and shown
per group on the usage tab and in the people page's panels. Requests refused
by a limit are recorded as 429s against their owner, free of charge, so a team
hitting its limits shows as such. Usage can be grouped by model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:50:16 +02:00
Ilker ManapandClaude Opus 5.5 4454e1322c Read usage from sums kept as it is written, not from every request
Charts, totals and budgets summed the request table on every view. Usage is
now also summed in the writing transaction into five-minute, hourly and daily
tables per key, person, project and model, with latency and first-token
histograms. Charts read the coarsest tier their slices allow; totals and
budgets compose rows, slices, hours and days, and stay exact. Request records
can be given a retention without moving any total.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:43:10 +02:00
Ilker ManapandClaude Opus 5.5 febcf251e4 Send an administrator from /org to the people page's panel
Release / build (push) Successful in 1m16s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 3m56s
An organization's members, limits and use are a panel of the people page,
where they can also be changed, so the administrator's menu loses its
Organizations entry and /org redirects there. Managers keep /org.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:15:25 +02:00
Ilker ManapandClaude Opus 5.5 fffed716be Make people, limits and usage one page, with a panel per branch
CI / test (push) Successful in 3m57s
Release / build (push) Successful in 1m14s
Release / notes (push) Successful in 3s
A team's members, quota and spend were on three pages that linked to each
other. They are three tabs of one page now, each at its old address, and
choosing anything in the tree opens its limits, capacity and use beside it,
editable in place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 19:10:48 +02:00
Ilker ManapandClaude Opus 5.5 1b89d5cdea Say on the chat page whether a GPU or a CPU answered
Release / notes (push) Successful in 3s
Release / build (push) Successful in 1m12s
CI / test (push) Successful in 3m54s
A model with replicas on a card and on CPUs answered at either speed with no
way to tell which. The picker names where each model runs, each reply names
the hardware that served it, and every answer carries x-llmgw-hardware.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 18:57:50 +02:00
Ilker ManapandClaude Opus 5.5 477d0bf19d Judge a card shared with another process every heartbeat, not once
Release / build (push) Successful in 1m14s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 3m50s
A card with something else on it when the agent started stayed out of
placement after that process let go of it, until the agent restarted. The
agent now sends what processes other than its own engines hold on each card,
and the control plane replaces the registration verdict with it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-03 18:46:23 +02:00
Ilker ManapandClaude Opus 5.5 372ef64889 Count the GPU time each owner's models held, on the usage page
CI / test (push) Successful in 3m48s
Release / build (push) Successful in 1m11s
Release / notes (push) Successful in 3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:07:59 +02:00
Ilker ManapandClaude Opus 5.5 82a63cfc6e Read what wears a GPU out: XIDs, remapped rows, PCIe and NVLink errors
Every minute the agent reads nvidia-smi's full report, its NVLink error
counters and the kernel's XID lines. They are published per card,
shown on the node page and the GPU map, charted in Grafana, and five
alerts watch them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 16:02:07 +02:00
Ilker ManapandClaude Opus 5.5 ff554c7062 Show what each model gets from its GPUs, per card and per watt-hour
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:56:42 +02:00
Ilker ManapandClaude Opus 5.5 b6a802c03b Map the fleet's GPUs, and name the cards held and idle
The cluster page draws every card coloured by utilisation, memory or
temperature, and lists replicas whose every card is under 5% busy.
llmgw_gpu_assigned says which replica, model and owner hold each card.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:54:58 +02:00
Ilker ManapandClaude Opus 5.5 bd36798b15 Sample GPU utilisation and memory every heartbeat, and chart them
Release / notes (push) Successful in 4s
Release / build (push) Successful in 1m12s
CI / test (push) Successful in 3m52s
They were read once, at agent start, and published unchanged since.
Each sample now carries utilisation, memory and bandwidth in use, the SM
clock and uncorrected ECC errors. The node page charts each card, the
cluster page the fleet by class, Grafana has a GPU row, and three GPU
alerts are added.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 15:42:17 +02:00
Ilker ManapandClaude Opus 5.5 dc483d8c68 Choose each engine's build from the node's hardware, for every GPU generation
Release / build (push) Successful in 1m12s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m52s
Each engine has a list of builds, best first, from what its project
publishes and how each was compiled: llama.cpp CUDA 13.3, CUDA 12.8
(driver 570 for the GPUs it ships as PTX), then Vulkan, never the CPU
build on an NVIDIA node; vLLM CUDA 13, then CUDA 12.9, and for GPUs
older than every build of the version asked for, the newest built for
them (V100: 0.19.1). The node takes the first its hardware meets and
that installs and computes on the GPU, moving on when one does not, and
reports the version it runs. GPU processes are read from nvidia-smi's
full report, so Vulkan engines count as on their GPU.

Refs #2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:35:58 +02:00
Ilker ManapandClaude Opus 5.5 43d7c4eac1 Let the upgrade repair what the field's catalog already holds
Release / build (push) Successful in 1m11s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m51s
tp on a llama.cpp GPU profile is read as gpus_per_node rather than
refused, and a Hugging Face repository page as a vLLM source as the
repository id, so models written that way are placed after the upgrade
instead of dropped from the catalog. Engines adopted after an upgrade
are checked for computing on their GPUs too.

Refs #2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:20:11 +02:00
Ilker ManapandClaude Opus 5.5 86e9a1cb83 Give V100s the last vLLM built for them, not a build without their kernels
Release / build (push) Successful in 1m13s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 3m47s
Read from vLLM's release pipeline: every x86 build from 0.20.0 has
kernels for compute 7.5 and up, the CUDA 12.9 one included, and 0.19.1
is the last built for 7.0. PyPI's build is CUDA 13 from 0.20, not 0.26.
A V100 asked for a newer vLLM is refused, naming 0.19.1 for
engine_overrides; the CUDA 12.9 fallback is for older drivers on
Turing and newer. The cu129 wheel is found among the release's assets,
since its manylinux tag differs between versions.

Refs #2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 14:04:45 +02:00
Ilker ManapandClaude Opus 5.5 4f280a3546 Give each GPU node a vLLM build it can run, chosen on the cluster page
Release / build (push) Successful in 1m12s
Release / notes (push) Successful in 3s
CI / test (push) Successful in 3m55s
A node that cannot run the CUDA 13 build installs the same version's
CUDA 12.9 build from vLLM's GitHub release, with PyTorch's cu129 torch;
the install computes on the GPU before it counts. The build can be
chosen per node — auto, cu13, cu129, none — on the cluster page, with
ctl engine-build, or over the admin API; the control plane keeps the
choice and tells the node at once.

Refs #2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:39:14 +02:00
Ilker ManapandClaude Opus 5.5 2735c5a318 Mirror a many-file model whole, with its checksums, for vLLM
Release / build (push) Successful in 1m12s
CI / test (push) Successful in 3m58s
Release / notes (push) Successful in 3s
The explorer fetches a safetensors set with its config and tokenizer;
the mirror gives out a version's manifest only once nothing is
downloading and every shard its index names is there, with each file's
sha256, which nodes check; mirror add takes several URLs; a repository
page is refused as a source.

Refs #2 (item 9).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:21:04 +02:00
Ilker ManapandClaude Opus 5.5 8957db30a2 Recognise a duplicate sign-up on PostgreSQL, and say what to do
A taken address on PostgreSQL fell through to a generic error, because
the store looked for SQLite's "UNIQUE". It checks code 23505 too. The
done page tells someone whose address had an account to sign in or ask
for a reset, without saying which case it was; other failures give a
logged reference.

Refs #2 (item 6).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:15:37 +02:00
Ilker ManapandClaude Opus 5.5 2ef4220643 Fail a node only for the fabrics it declares
A RoCE port without a v2 GID and a missing ib_uverbs failed doctor, and
node add, whatever fabric used them. They are now failures only for a
declared fabric and warnings otherwise; node add passes its --fabric
list to doctor so those are checked as declared.

Refs #2 (item 5).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:13:18 +02:00
Ilker ManapandClaude Opus 5.5 6014d1fee8 Say how a restart with no room beside it goes
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:11:46 +02:00
Ilker ManapandClaude Opus 5.5 a3ee48aced Restart in place at once when only the old replica is in the way
A restart with no room beside it waited the drain grace period before
stopping the old replica, and stopped it even when the replacement
would fit nowhere. It now stops at once when the replica alone blocks
its replacement, gives the restart up (saying why) when nothing would
fit, and ctl restart --in-place stops first on request.

Refs #2 (item 3).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:11:37 +02:00
Ilker ManapandClaude Opus 5.5 e313f32696 Decide engines per node, and let a node join without one it cannot run
engine_overrides give nodes picked by GPU class, id or labels their own
engine versions; node add --disable-engine keeps one off a node; what
vLLM cannot do on a node is a warning rather than a refused node; the
CUDA requirement follows the version; a hand-named binary is no longer
replaced by an installed one.

Refs #2 (items 4 and 7).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 12:07:25 +02:00
Ilker ManapandClaude Opus 5.5 cba3e329fc Let the mirror's serve form place a model on a GPU
The form offered no placement and named every GGUF profile cpu. It now
lists the fleet's GPU classes (the freest chosen) and the CPU, a GPU
count and parallel requests, names the profile from the placement, shows
what the model will run as, and refuses the CPU while GPUs are free
unless that is ticked.

Refs #2 (item 1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:59:22 +02:00
Ilker ManapandClaude Opus 5.5 cbd5521f4f Place llama.cpp across several GPUs of a node with gpus_per_node
tp on a llama.cpp profile was a width nothing placed. gpus_per_node is
now the count: placement reserves that many cards on one node and the
engine splits layers across them. tp, pp and nodes are refused with
what to write instead.

Refs #2 (item 8).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:56:34 +02:00
Ilker ManapandClaude Opus 5.5 057be16a92 Do not call a GPU shard ready while it computes on the CPU
llama.cpp whose CUDA backend cannot load runs on the CPU without an
error. The CUDA build is now asked for its devices when installed, a
shard on GPUs must hold memory on each before it is ready, and
engine-check reports the devices. Shards get CUDA_DEVICE_ORDER=PCI_BUS_ID
so CUDA numbers GPUs as placement does.

Refs #2 (item 2).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 11:54:39 +02:00
Ilker ManapandClaude Opus 5.5 0c7934ba96 v0.10.6
Release / notes (push) Successful in 4s
Release / build (push) Successful in 1m11s
CI / test (push) Successful in 3m51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 08:52:01 +02:00
Ilker ManapandClaude Opus 5.5 e40b9ac202 Load LoRA adapters at scale zero
CI / test (push) Successful in 4m0s
Release / build (push) Successful in 1m17s
Release / notes (push) Successful in 5s
--lora with --lora-init-without-apply leaves an adapter's scale at 1,
and llama-server applies it at that scale to any request without a
"lora" list of its own: every plain request for the model was served
with its adapters on. Found live on qwen2.5-coder-7b-instruct. Each
adapter is now loaded with --lora-scaled FILE:0.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 07:39:12 +02:00
Ilker ManapandClaude Opus 5.5 323d172538 Attach LoRA adapters from the command line
Release / notes (push) Successful in 4s
Release / build (push) Successful in 1m14s
CI / test (push) Successful in 3m52s
llmgw ctl model adapters lists a model's adapters and adds or removes
them through POST /api/v1/models/{name}/adapters, which reads each new
file first and refuses one that is not an adapter for the model's
architecture, as the mirror page's card does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 07:30:13 +02:00
Ilker ManapandClaude Opus 5.5 6b1fa65f59 Format kind_live_test.go
Release / notes (push) Successful in 3s
Release / build (push) Successful in 1m12s
CI / test (push) Successful in 4m3s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 07:20:35 +02:00
Ilker ManapandClaude Opus 5.5 663770226e Find LoRA adapters on Hugging Face and attach them from the mirror page
Release / notes (push) Successful in 3s
CI / test (push) Failing after 33s
Release / build (push) Successful in 1m3s
An adapters view in the explorer, filtered by the model a held model was
made from; GGUF files told apart as adapter or model by their first bytes;
held adapter files marked; and a model's card attaches and detaches held
adapters of its architecture, refusing models and mismatched architectures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-01 07:19:08 +02:00
Ilker ManapandClaude Opus 5.5 cf53904cc5 Serve LoRA adapters on llama.cpp models, asked for by name
Release / build (push) Successful in 1m11s
CI / test (push) Successful in 3m47s
Release / notes (push) Successful in 3s
A model's adapters are fetched by each node with its weights and loaded
unapplied at start; a request naming an adapter reaches the model's
replicas with llama.cpp's per-request lora field. Adapter names share the
model namespace, are listed, and follow allowlists on their own. The
router's config seed and pushed catalog now build the name map in one place.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:40:47 +02:00
Ilker ManapandClaude Opus 5.5 94eec557ad Serve a model from an external OpenAI-compatible provider
CI / test (push) Successful in 3m47s
Release / build (push) Successful in 1m11s
Release / notes (push) Successful in 3s
A catalog model with external {url, model, api_key_file} and no profiles
is published as an endpoint by the control plane, the key read from its
file each time; the router rewrites the request's model to the provider's
name and sends its key. It is never scraped nor stale, and nothing is
placed for it. Named in fallbacks, it serves when the cluster has no room.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:29:01 +02:00
Ilker ManapandClaude Opus 5.5 d5f02c0a4b Spill a saturated pool's requests to the next pool
CI / test (push) Successful in 3m40s
The pool-priority filter kept the highest-priority pool however full it
was. It now takes the first pool, by priority, that is not saturated by
admission's own measure, and the preferred one when all are.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-30 23:23:32 +02:00
Ilker ManapandClaude Opus 5.5 3d3c96e422 Displace the newest lowest-band request when the gateway queue is full
Release / notes (push) Successful in 3s
Release / build (push) Successful in 1m9s
CI / test (push) Successful in 3m50s
A total bound across bands (admission.queue_capacity_total, default half of
all bands' capacity together); reached, a request is not refused while a
lower band has requests queued, and the newest of the lowest band gets a 503
with Retry-After instead.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Also formats two files that went in unformatted.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    sudo llmgw node add ubuntu@10.0.0.21

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

users move is gone; join and leave replace it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 16:26:39 +02:00
1301 changed files with 238846 additions and 5447 deletions

No files matched your search

+23 -1
View File
@@ -34,11 +34,33 @@ 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
# acceptance tests (TF_ACC=1) are left out: they need Terraform itself.
- name: Test the Terraform provider
working-directory: terraform-provider-llmgw
run: |
go vet ./...
go test ./...
# The NVML backend needs cgo and cannot run without a driver, but it must
# at least keep compiling: it is the only source of NVLink topology and a
# break would otherwise surface on a GPU node, far from here.
+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
+41
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,8 +106,34 @@ 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
# nothing the server alone could have put there. `release sign` checks
# the signature against that key before writing it, so a secret that
# does not match trusted.pub stops the release too.
- name: Sign the checksums
env:
RELEASE_SIGNING_KEY: ${{ secrets.RELEASE_SIGNING_KEY }}
run: |
set -euo pipefail
version="${GITHUB_REF_NAME:-$(git describe --tags --always)}"
if [ -z "${RELEASE_SIGNING_KEY:-}" ]; then
# Builds that carry a trusted key install only signed releases:
# publishing an unsigned one would be a release no upgraded
# machine accepts, so it stops here instead.
if [ -s internal/release/trusted.pub ]; then
echo "internal/release/trusted.pub holds a key, and the RELEASE_SIGNING_KEY secret is not set:"
echo "add it under Settings -> Secrets, or this release could not be installed anywhere."
exit 1
fi
echo "no RELEASE_SIGNING_KEY secret and no trusted key; releases are not signed"
exit 0
fi
./dist/llmgw-${version}-linux-amd64 release sign dist/checksums.txt
echo "signed: dist/checksums.txt.sig"
- name: Attach to the release
env:
TOKEN: ${{ secrets.GITEA_TOKEN }}
+6
View File
@@ -5,3 +5,9 @@
*.db-shm
.DS_Store
coverage.out
# The provider built in place, and what Terraform leaves beside an example.
/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/
+2000
View File
File diff suppressed because it is too large. Load diff
+4303 -1
View File
File diff suppressed because it is too large. Load diff
+13 -1
View File
@@ -2,10 +2,11 @@ MODULE := gitea.mynodes.xyz/ilker/llmgw
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -X '$(MODULE)/internal/version.Version=$(VERSION)'
.PHONY: build test race lint fmt clean rename proto proto-tools
.PHONY: build test race lint fmt vuln check clean rename proto proto-tools
build:
go build -ldflags "$(LDFLAGS)" -o bin/llmgw ./cmd/llmgw
go build -ldflags "$(LDFLAGS)" -o bin/llmgw-kb ./cmd/llmgw-kb
test:
go test ./...
@@ -20,6 +21,17 @@ fmt:
go fmt ./...
go vet ./...
# Known vulnerabilities reachable from this code, in the standard library and
# in dependencies. A toolchain left behind is the commonest cause: the go
# directive in go.mod is what fixes it.
vuln:
go run golang.org/x/vuln/cmd/govulncheck@latest ./...
# What has to pass before a change is merged.
check: test
go vet ./...
$(MAKE) vuln
# Regenerate the agent protocol. Generated code is committed, so this only
# needs running when api/proto changes.
proto: proto-tools
+229 -39
View File
@@ -1,22 +1,25 @@
# llmgw
A single-binary LLM gateway for clusters with multiple InfiniBand fabrics and
heterogeneous GPUs.
A single-binary LLM gateway for clusters with multiple InfiniBand fabrics,
NVLink domains and heterogeneous GPUs.
It serves LLMs running on your own GPU servers (vLLM / SGLang) behind one
OpenAI-compatible endpoint. Design document: [llmgw-plan.md](llmgw-plan.md).
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
Starting from nothing — no packages installed, no drivers, no nodes — follow
[docs/install.md](docs/install.md): it builds a control plane, adds a node and
serves a model, and says plainly which parts llmgw does not install for you.
On the machine that will be the control plane, with the release binary
installed:
| Guide | For |
|---|---|
| [docs/install.md](docs/install.md) | Standing up a cluster from scratch |
| [docs/node-onboarding.md](docs/node-onboarding.md) | Adding nodes to one that exists |
| [docs/onboarding.md](docs/onboarding.md) | Giving people accounts and keys |
```sh
sudo llmgw
```
The setup screens install one machine or three (high availability), check
them first, and leave a model answering. After that the same command opens
the console. [docs/quick-install.md](docs/quick-install.md) has the six steps
from empty servers to a first request; everything else is below.
## Status
@@ -27,56 +30,209 @@ 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 |
| v0.8.0 | — | PostgreSQL, leader election, agent failover | released |
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
| 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 |
| 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.
Before v1.0.0: tracing, reference documentation, and filtering on `ctl nodes`
and `ctl replicas` for fleets too large to read a whole table of. Two items
that were on this list are not gaps: a model falls through to its fallbacks
when there is no capacity as well as when a budget is spent, and routing
profiles are chosen by the key rather than by a request header on purpose —
a header would let any client promote itself into the top priority band.
Since v0.11.0 (each release's detail is in [CHANGELOG.md](CHANGELOG.md)):
- **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** (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).
What is still ahead is in [BACKLOG.md](BACKLOG.md). Two things that look like
gaps are not: a model falls through to its fallbacks when there is no
capacity as well as when a budget is spent, and routing profiles are chosen
by the key rather than by a request header on purpose — a header would let
any client promote itself into the top priority band.
One thing is worth saying plainly:
multi-node replicas are proven on a simulated cluster and on real hardware
without a fabric. The InfiniBand, NVML and NCCL paths are written against the
formats and failure modes those tools actually have, but they have not yet run
on a machine that has them.
without a fabric. The InfiniBand, NVLink and NCCL paths — telling fabrics apart
by their subnet managers, mapping them, finding NVLink domains, launching across
nodes — are written against the formats and failure modes those tools actually
have and tested against them, but they have not yet run on a machine that has
them. On such a machine, run `llmgw doctor --json` first and keep the report;
[docs/fabrics.md](docs/fabrics.md) says what to look at after that.
## Documentation
- [docs/install.md](docs/install.md) — installing from nothing: control plane,
nodes, engines, weights, first request
- [docs/quickstart.md](docs/quickstart.md) — the short path
Every page is also in the binary: `llmgw docs`, or **Manual** in the console.
**Installing**
- [docs/quick-install.md](docs/quick-install.md) — from empty servers to a
first request, in six steps: start here
- [docs/install.md](docs/install.md) — the same with every option explained,
and running the cluster after: monitoring, notifications, single sign-on,
the firewall, upgrades, backups
- [docs/node-onboarding.md](docs/node-onboarding.md) — adding nodes to a
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
**Running it**
- [docs/console.md](docs/console.md) — the console that bare `llmgw` opens:
every view, and the command each of its actions runs
- [docs/onboarding.md](docs/onboarding.md) — accounts, keys, limits, staff
roles and single sign-on
- [docs/clients.md](docs/clients.md) — using the gateway from code with the
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
- [docs/data.md](docs/data.md) — what llmgw stores, what it does not, and
what leaves the machine; written for a security review
**Reference**
- [docs/reference.md](docs/reference.md) — every configuration key, generated
from the structs and checked by a test, so it cannot describe a setting that
does not exist or miss one that does
- [deploy/mirror/](deploy/mirror/) — a model mirror, which is the cheapest
and most useful thing to build around llmgw
- [deploy/proxy/](deploy/proxy/) — Caddy and nginx in front of the client
port, with streaming and the forwarded headers right
- [deploy/ha/](deploy/ha/) — running two control planes, and the one health
check that has to be right
- [docs/node-onboarding.md](docs/node-onboarding.md),
[docs/onboarding.md](docs/onboarding.md) — adding machines, and adding people
- [docs/data.md](docs/data.md) — what llmgw stores, what it does not, and
what leaves the machine; written for a security review
- [docs/admin-api.md](docs/admin-api.md) and its OpenAPI document — the
admin API, for scripts; [terraform-provider-llmgw/](terraform-provider-llmgw/)
manages models, quotas and guardrails with Terraform or OpenTofu
- [deploy/](deploy/) — [a mirror](deploy/mirror/) on its own machine,
[Caddy and nginx](deploy/proxy/) in front of the client port, [two control
planes](deploy/ha/) behind a balancer, and [Prometheus, Alertmanager and
Grafana](deploy/monitoring/)
**Working on llmgw**
- [docs/development.md](docs/development.md) — built from source on one
workstation, no systemd and no `sudo`
- [llmgw-plan.md](llmgw-plan.md) — the design; [BACKLOG.md](BACKLOG.md) —
what is next
## Building
```sh
make build # bin/llmgw
make build # bin/llmgw and bin/llmgw-kb
make test # unit tests
make race # with the race detector
```
@@ -90,8 +246,14 @@ llmgw ctl replicas --model llama # replicas and their shards
llmgw ctl explain-placement llama # why a model has not been placed
llmgw ctl drain gpu-a-01 # move work off a node, one replica at a time
llmgw ctl cordon gpu-a-01 # stop new placement, leave what runs
sudo llmgw doctor # on a node: what its GPUs, NVLink and fabric need
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.
`ctl` talks to the admin port (`:9090` by default), not the client-facing one.
Add `--json` to any command for machine-readable output.
@@ -99,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
@@ -126,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
+3438 -173
View File
File diff suppressed because it is too large. Load diff
+540
View File
@@ -24,9 +24,41 @@ message AgentMessage {
Heartbeat heartbeat = 2;
ShardStatus shard = 3;
LogsReply logs = 4;
FabricScanReply fabric_scan = 5;
FabricTestReply fabric_test = 6;
UpgradeReply upgrade = 7;
KVEvents kv_events = 8;
TrainStatus train_status = 9;
TrainArtifact train_artifact = 10;
}
}
// KVEvents is a change to what one vLLM replica's prefix cache holds, read
// by the agent from the engine's KV cache events and sent on for the router's
// precise index (plan §7.5 tier 2).
//
// A net effect since the last one, not the events themselves: a block stored
// and evicted between two sends is not worth the bytes. On every new stream
// the agent first sends each replica in full, with clear_first set, because a
// control plane that has just started or taken over knows nothing.
message KVEvents {
string replica_id = 1;
// block_size is how many tokens make one of the replica's blocks.
int32 block_size = 2;
// clear_first says to forget what was known of the replica before applying the
// rest: its cache was cleared, its engine restarted, events were lost, or
// this is the first piece of a full copy.
bool clear_first = 3;
// stored and removed are block hashes as vLLM publishes them: the low 64
// bits of the sha256_cbor digest.
repeated fixed64 stored = 4;
repeated fixed64 removed = 5;
// problem, when set, is why the replica's events cannot be used — most
// likely an engine hashing another way than llmgw configured. Its index is
// empty while it stands.
string problem = 6;
}
// ControlMessage is anything the control plane tells a node.
message ControlMessage {
oneof msg {
@@ -37,7 +69,62 @@ message ControlMessage {
RemoveWeights remove_weights = 5;
Provision provision = 6;
LogsRequest logs = 7;
FabricScanRequest fabric_scan = 8;
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;
}
// 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
// agent does for it — fetching weights, launching the engine, waiting for
// it to answer — lands in the same trace. Empty when the control plane is
// not tracing, and ignored by an agent that is not.
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
// was onboarded is one it fails over to without anybody editing the node.
message ControlPlanes {
repeated string servers = 1;
}
// Register is the first message on every stream. It carries the node's full
@@ -114,6 +201,25 @@ message Register {
// meant "an agent too old to say" and constrained nothing — so a freshly
// built node with no engine installed yet was a candidate for every model.
bool engines_probed = 14;
// cpu_count is the CPUs the agent may run on. Placement reserves cores
// for CPU replicas out of it, the way it reserves memory out of
// memory_total_mb; zero, from an older agent, reserves nothing.
int32 cpu_count = 18;
// imex is the service that lets a multi-node NVLink domain share memory.
IMEX imex = 15;
// installable_engines are the engines this agent knows how to install
// itself. The control plane asks it for no others. Empty is an agent older
// than the field, which could install llama.cpp and nothing else: asked for
// vLLM, it answered "unsupported", and every node of a cluster half way
// through an upgrade showed as failing.
repeated string installable_engines = 16;
// features are the requests this agent answers beyond the ones every agent
// does: "fabric-test". An agent that does not know a request ignores it, so
// without this the control plane would wait minutes for an answer that is
// never coming.
repeated string features = 17;
}
// Host describes the operating system and processor of a node.
@@ -132,6 +238,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.
@@ -183,6 +296,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 {
@@ -207,6 +325,43 @@ message GPU {
// Zero when the driver is too old to report it, which is not the same as
// idle: the metric is simply absent rather than wrong.
int32 utilization_percent = 15;
// nic_paths is how far each network adapter is from this card, by HCA
// name: PIX, PXB, PHB, NODE or SYS, from nvidia-smi topo.
map<string, string> nic_paths = 16;
// The card's multi-node NVLink membership, from nvidia-smi -q: state is
// Completed once the fabric manager has joined it; cluster_uuid and
// clique_id name the domain it joined.
string fabric_state = 17;
string fabric_status = 18;
string cluster_uuid = 19;
string clique_id = 20;
string fabric_bandwidth = 21;
// mig is set when this is a MIG instance rather than a whole card. index
// is then 1000 + 100 × card + device, uuid the instance's (MIG-…), and
// 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.
message MIGDevice {
int32 parent_index = 1;
string parent_uuid = 2;
// parent_memory_total_mb is the whole card's: the class is named from it.
int32 parent_memory_total_mb = 3;
// device is the instance's number on its card, as nvidia-smi -L lists it.
int32 device = 4;
// profile is NVIDIA's name for the slice, like 3g.40gb.
string profile = 5;
int32 gpu_instance = 6;
int32 compute_instance = 7;
}
message FabricAttachment {
@@ -231,6 +386,165 @@ message LocalWeights {
bool shared = 3;
}
// NodeLoad is a node's use of its own CPU and memory, and each engine's share.
//
// Carried on the heartbeat rather than scraped from the node: the control
// plane already exports the fleet from one place, and a node exporter per
// machine would be a scrape target, a port and a firewall rule each.
message NodeLoad {
// cpu_percent is the whole machine, 0 to 100 however many cores it has.
double cpu_percent = 1;
int32 cpu_count = 2;
// The kernel's run-queue averages over 1, 5 and 15 minutes.
double load1 = 3;
double load5 = 4;
double load15 = 5;
// memory_used_bytes is total less available: what the machine could not
// hand to a new process without evicting something.
int64 memory_used_bytes = 6;
int64 memory_available_bytes = 7;
repeated ShardLoad shards = 8;
// What the machine's own sensors say; zero is not measured. cpu_temp_c is
// the hottest CPU sensor, cpu_power_w the CPU packages' draw from RAPL,
// inlet_temp_c the board's inlet or ambient sensor where it has one.
double cpu_temp_c = 9;
double cpu_power_w = 10;
double inlet_temp_c = 11;
repeated GPUSensor gpus = 12;
}
// GPUSensor is one card's heat, power, and what is holding it back.
message GPUSensor {
int32 index = 1;
double temp_c = 2;
double memory_temp_c = 3;
double power_w = 4;
double power_limit_w = 5;
// throttle names what is holding the clocks down now: sw_power_cap,
// hw_slowdown, sw_thermal, hw_thermal, power_brake.
repeated string throttle = 6;
// How busy the card is, sampled with the rest, rather than read once at
// registration. -1 is not reported by the driver. memory_total_mb is set
// by every agent that sends these, so zero there is an older agent.
double utilization_percent = 7;
double memory_utilization_percent = 8;
double memory_used_mb = 9;
double memory_total_mb = 10;
double sm_clock_mhz = 11;
// ecc_uncorrected counts uncorrected ECC errors since the driver loaded.
double ecc_uncorrected = 12;
// What wears the card out, read every minute; has_wear says these are
// present. Counts of -1 are not reported for this card.
bool has_wear = 13;
int64 pcie_replays = 14;
int64 retired_pages_sbe = 15;
int64 retired_pages_dbe = 16;
bool retirement_pending = 17;
int64 remapped_rows_correctable = 18;
int64 remapped_rows_uncorrectable = 19;
bool remap_pending = 20;
bool remap_failure = 21;
int64 nvlink_crc_errors = 22;
int64 nvlink_replay_errors = 23;
int64 nvlink_recovery_errors = 24;
// xid_errors are the driver's error events since the agent started.
int64 xid_errors = 25;
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.
message ShardLoad {
string shard_id = 1;
// cpu_cores is in cores, the way top counts a process: 2.5 is two and a
// half cores busy, which a percentage of the machine would hide on a large
// one.
double cpu_cores = 2;
int64 memory_rss_bytes = 3;
}
// IBPort is one InfiniBand or RoCE port's state and counters.
message IBPort {
string hca = 1;
int32 port = 2;
// state is ACTIVE, INIT, DOWN...; phys_state tells a cable unplugged
// (Polling, Disabled) from a link up with no subnet manager (LinkUp and INIT).
string state = 3;
string phys_state = 4;
int32 rate_gbps = 5;
// link_layer is InfiniBand or Ethernet (RoCE).
string link_layer = 6;
// counters are cumulative, by sysfs name; data counters in bytes.
map<string, uint64> counters = 7;
// Identity: the adapter's node GUID and the port's own, as the fabric
// knows them.
string node_guid = 8;
string port_guid = 9;
// The subnet manager this port sees as master, on InfiniBand. Every port on
// one physical fabric sees the same one, which is what tells separate
// fabrics apart when their names are only what somebody typed.
string sm_guid = 10;
int32 sm_lid = 11;
int32 sm_priority = 12;
string sm_state = 13;
// sm_error says why sm_guid is empty on an active InfiniBand port:
// infiniband-diags missing, or no subnet manager answering.
string sm_error = 14;
// network is, on RoCE, the IP network of the RoCE v2 address: RoCE has no
// subnet manager, and the network is what its fabric is.
string network = 15;
string gid_prefix = 16;
// subnet_managers is every subnet manager the fabric's subnet
// administrator lists — master and standbys — asked through one port per
// fabric, so it is on that port only. It is what keeps a fabric one fabric
// through a failover: the master changes, the list does not.
repeated SubnetManager subnet_managers = 17;
string subnet_managers_error = 18;
}
// SubnetManager is one subnet manager on a fabric.
message SubnetManager {
string guid = 1;
int32 lid = 2;
int32 priority = 3;
// state is master, standby, discovering or not active.
string state = 4;
}
// IMEX is the nvidia-imex service and its channels.
message IMEX {
// state is systemd's word for it, empty where it is not installed.
string state = 1;
int32 channels = 2;
}
// GPUFabricState is one GPU's NVLink domain membership.
message GPUFabricState {
int32 index = 1;
string state = 2;
string status = 3;
string cluster_uuid = 4;
string clique_id = 5;
string bandwidth = 6;
}
message Heartbeat {
string node_id = 1;
// gpu_memory_used_mb, indexed by GPU, catches a foreign process appearing on
@@ -271,6 +585,45 @@ message Heartbeat {
// than "nothing to say", as for weights.
bool provision_changed = 10;
repeated ProvisionStatus provision = 11;
// load is how busy the machine is, sampled over the interval since the
// previous heartbeat. Absent on the first one, which has nothing to take a
// difference from, and from an agent on a system without /proc.
NodeLoad load = 12;
// ib_ports is every InfiniBand or RoCE port as it stands now, re-read on
// each heartbeat: a port that goes down after the agent started used to go
// unnoticed until the agent restarted.
repeated IBPort ib_ports = 13;
// fabrics is the fabrics this node can now carry traffic on, sent only when
// that changed since registration or the last heartbeat that said so — a
// port going down takes its fabric away, and one coming back returns it.
bool fabrics_changed = 14;
repeated FabricAttachment fabrics = 15;
// gpu_fabric is each GPU's NVLink domain membership as it stands now, sent
// once a minute: a fabric manager restarting drops every card out of its
// domain, and the node has to stop being offered work that spans it.
repeated GPUFabricState gpu_fabric = 16;
IMEX imex = 17;
// cpu_count as in Register, for a VM given more CPUs while it runs.
int32 cpu_count = 18;
// gpu_foreign_memory_mb, indexed by GPU, is what processes that are not
// this agent's shards hold on each card, read afresh every heartbeat. What
// keeps a card out of placement for another tenant is decided from this
// rather than from registration: a card that had something else on it when
// the agent started is not taken forever, and one that gains a tenant later
// 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
@@ -291,6 +644,18 @@ message Provision {
message EngineWant {
string engine = 1; // "llamacpp"
string version = 2; // "b10991"
// variant is the build to install, when one is chosen for the node rather
// than left to its hardware: "cu13", "cu129". Empty is the agent's choice.
string variant = 3;
// offline_base, on a cluster with no way to the internet, is where this
// engine version's files are on its mirror:
// http://mirror:8081/_llmgw/engines/llamacpp/b10991. Set, the node never
// asks the engine's publisher.
string offline_base = 4;
// offline_sha256 is each of those files' checksum by name, from the
// bundle the control plane trusts — not from the mirror, which serves
// every node over plain HTTP.
map<string, string> offline_sha256 = 5;
}
// ProvisionStatus is where one engine's installation stands on a node.
@@ -304,6 +669,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;
@@ -325,6 +702,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;
@@ -345,6 +726,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
@@ -377,6 +761,13 @@ message StartShard {
// profile_fp identifies the version of the profile, so the agent can say
// which one a running shard was started from. The agent never interprets it.
string profile_fp = 15;
// api_key is what the engine demands of every request, derived by the
// control plane from the replica id. The router sends it; nothing else
// that can reach the node's ports has it.
string api_key = 16;
// cpu_threads is how many CPU threads a shard with no GPU computes on:
// the cores placement reserved for it. Zero leaves it to the agent.
int32 cpu_threads = 17;
}
message EngineProfile {
@@ -391,6 +782,19 @@ message EngineProfile {
map<string, string> env = 9;
string model_path = 10;
string served_model_name = 11;
// adapters are LoRA adapters loaded beside the model, in order: the index
// is the id a request applies one by.
repeated LoraAdapter adapters = 12;
// 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.
@@ -446,3 +850,139 @@ message LogsReply {
// error says why the lines are absent: an unknown source, say.
string error = 4;
}
// FabricScanRequest asks a node to map the fabric one of its ports is on,
// with ibnetdiscover: every switch, every adapter and every link between
// them. One node per fabric is asked, every few minutes; a fabric looks the
// same from all of its ports.
message FabricScanRequest {
string request_id = 1;
string hca = 2;
int32 port = 3;
}
// FabricScanReply is ibnetdiscover's output as it printed it. Parsed by the
// control plane, so a format the parser does not yet know is a control plane
// upgrade rather than one on every node.
message FabricScanReply {
string request_id = 1;
string output = 2;
string error = 3;
int64 took_ms = 4;
}
// FabricTestRequest asks a node for one side of an RDMA bandwidth test
// between two nodes: ib_write_bw, from perftest, over one adapter. The
// server side is asked first and answers when the test is over; the client
// dials it. What it proves is what a replica spanning nodes needs and nothing
// on one node can show: that these two adapters reach each other through the
// fabric, and how fast.
message FabricTestRequest {
string request_id = 1;
// role is "server" or "client".
string role = 2;
string hca = 3;
int32 port = 4;
// peer is the server's management address, for the client. perftest
// exchanges its connection details over TCP before any RDMA.
string peer = 5;
// tcp_port is that exchange's port, the same on both sides.
int32 tcp_port = 6;
int32 seconds = 7;
}
// FabricTestReply is what one side measured.
message FabricTestReply {
string request_id = 1;
// gbps is the average bandwidth, in gigabits a second.
double gbps = 2;
// output is the end of the tool's output, for when the number is not
// enough.
string output = 3;
string error = 4;
int64 took_ms = 5;
}
// UpgradeAgent tells an agent to replace its own binary with a release and
// restart.
//
// The agent fetches the release for its own platform and checks it against
// the release's checksums before replacing anything, keeps the old binary
// beside the new, then exits so systemd starts the new one. Engines keep
// running throughout, as for any agent restart, and are adopted on the way
// back. Over the stream the agent already holds, so upgrading a fleet needs
// no ssh into it.
message UpgradeAgent {
string request_id = 1;
// version must be a release: v0.10.4 or v0.10.4-rc.2.
string version = 2;
// releases is the base URL release files are downloaded from.
string releases = 3;
}
// UpgradeReply says whether the new binary is in place. Sent before the
// agent exits; an empty error means it will be back on the new version.
message UpgradeReply {
string request_id = 1;
string version = 2;
string error = 3;
}
// LoraAdapter is one adapter: the name clients ask for it by, and its source,
// which the agent fetches as it does the model's weights.
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;
}
+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)
}
}
}
}
+2
View File
@@ -5,6 +5,8 @@ package main
import (
"fmt"
"os"
// Time zones for scaling schedules, on a machine without the tz database.
_ "time/tzdata"
"gitea.mynodes.xyz/ilker/llmgw/internal/cli"
)
+16 -8
View File
@@ -35,8 +35,9 @@ usage: $0 --node ID --addr HOST --server URL [options]
--mgmt-if NAME interface NCCL bootstraps over (default: the one holding
that address)
--llamacpp-bin P use this llama-server instead of the one the agent installs
--vllm-bin P path to the vllm entry point (not yet installed by the agent)
--sglang-python P python interpreter of the SGLang virtualenv
--vllm-bin P use this vllm instead of the one the agent installs on a
GPU node (also set engines.vllm: "" in server.yaml)
--sglang-python P python interpreter of an SGLang virtualenv you installed
--nvidia-smi PATH where nvidia-smi lives, when it is not on PATH. Containerized
driver installs routinely put it somewhere else.
--ib-root DIR the sysfs InfiniBand directory (default /sys/class/infiniband)
@@ -121,9 +122,10 @@ TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
"$BINARY" ctl cert issue-client "$NODE" --dir "$PKI" --out "$TMP" >/dev/null
# 2 and 3. Identity, and trusting the root so future tooling on that machine
# does not treat our certificates as strangers.
say "installing the certificate and trusting the root"
# 2 and 3. Identity. The root is trusted by the agent's configuration only,
# never system-wide: it signs whatever the control plane asks, and every
# TLS client on the machine would take it for any site.
say "installing the certificate"
scp -q $SSHOPTS "$PKI/ca.pem" "$TMP/$NODE.pem" "$TMP/$NODE-key.pem" "$SSH_USER@$ADDR:/tmp/"
$SSH "set -e
sudo install -d -m 0755 /etc/llmgw
@@ -131,8 +133,10 @@ $SSH "set -e
sudo install -m 0644 /tmp/$NODE.pem /etc/llmgw/agent.pem
sudo install -m 0600 /tmp/$NODE-key.pem /etc/llmgw/agent-key.pem
sudo chown root:root /etc/llmgw/*
sudo install -m 0644 /tmp/ca.pem /usr/local/share/ca-certificates/llmgw-ca.crt
sudo update-ca-certificates >/dev/null 2>&1 || true
if [ -f /usr/local/share/ca-certificates/llmgw-ca.crt ]; then
sudo rm -f /usr/local/share/ca-certificates/llmgw-ca.crt
sudo update-ca-certificates --fresh >/dev/null 2>&1 || true
fi
rm -f /tmp/ca.pem /tmp/$NODE.pem /tmp/$NODE-key.pem"
# 4. The binary, the configuration and the unit.
@@ -142,7 +146,7 @@ $SSH "set -e
# that is not a release (a developer's, with commits past the tag) has no such
# file, and is copied as before.
TAG="$("$BINARY" --version 2>/dev/null | awk '{print $NF}')"
if printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
if printf '%s' "$TAG" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+(-rc\.[0-9]+)?$'; then
say "installing llmgw $TAG from the release"
$SSH "set -e
arch=\$(dpkg --print-architecture)
@@ -226,6 +230,10 @@ ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
KillMode=process
Restart=on-failure
RestartSec=2s
# RDMA pins the buffers it registers, and NCCL registers a great deal: with
# systemd's default 8 MB lock limit, an engine that drops the capability that
# lets root ignore it fails its first collective over InfiniBand.
LimitMEMLOCK=infinity
StateDirectory=llmgw-agent
[Install]
+40
View File
@@ -0,0 +1,40 @@
# The llmgw control plane as a container image, for deploy/helm/llmgw.
#
# The project publishes binaries, not an image, so this is built where the
# image is to live:
#
# docker build -f deploy/container/Dockerfile --build-arg VERSION=v0.11.1 \
# -t registry.internal/llmgw:v0.11.1 .
# docker push registry.internal/llmgw:v0.11.1
#
# The binary is static (CGO_ENABLED=0, as every release is), so the image is
# that one file on a base with nothing else in it: no shell, no package
# manager. `kubectl exec ... -- llmgw ctl status` still works, because it
# runs the binary rather than a shell.
#
# Only the control plane runs from it. An agent installs engines on its
# machine, manages its GPUs and supervises processes there as root, which is
# what a container is built to prevent: docs/kubernetes.md.
FROM golang:1.26 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags "-s -w -X gitea.mynodes.xyz/ilker/llmgw/internal/version.Version=${VERSION}" \
-o /out/llmgw ./cmd/llmgw && \
mkdir -p /out/state
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/llmgw /usr/local/bin/llmgw
# The chart mounts server.yaml, the certificates and the token under
# /etc/llmgw, and the SQLite store, when it is one, under /var/lib/llmgw —
# owned by the user the server runs as, so a volume Docker fills from the
# image is writable without Kubernetes' fsGroup.
COPY --from=build --chown=65532:65532 /out/state /var/lib/llmgw
USER 65532:65532
EXPOSE 8080 7443 9091
ENTRYPOINT ["/usr/local/bin/llmgw"]
CMD ["server", "--config", "/etc/llmgw/server.yaml"]
+58
View File
@@ -0,0 +1,58 @@
# Arithmetic a small instruction model gets right: sums, products and a
# little reading, each answer scored by the last number in it. A quantisation
# that has gone wrong, or a chat template that no longer matches the
# weights, shows up here as a falling score long before anyone notices it in
# conversation.
#
# llmgw ctl eval MODEL --suite arithmetic
# llmgw ctl model update MODEL --eval arithmetic --min-score 0.8
#
# Copy it into control.eval_dir (/etc/llmgw/eval) to change it; a suite
# there of the same name replaces this one.
name: arithmetic
description: Sums, products and word problems, each scored by the number in its answer.
threshold: 0.7
system: You are a careful calculator. Answer with the number only, without words or units.
max_tokens: 64
cases:
- id: add-small
prompt: What is 17 + 25?
number: 42
- id: add-carry
prompt: What is 389 + 476?
number: 865
- id: subtract
prompt: What is 1000 - 357?
number: 643
- id: subtract-negative
prompt: What is 12 - 30?
number: -18
- id: multiply
prompt: What is 12 * 12?
number: 144
- id: multiply-larger
prompt: What is 23 * 17?
number: 391
weight: 2
- id: divide
prompt: What is 144 / 6?
number: 24
- id: decimal
prompt: What is 2.5 * 4?
number: 10
- id: percent
prompt: What is 15% of 200?
number: 30
- id: words-apples
prompt: Ana has 7 apples and buys 5 more, then gives 3 away. How many apples does she have?
number: 9
weight: 2
- id: words-time
prompt: A train leaves at 14:00 and the trip takes 150 minutes. How many hours does the trip take?
number: 2.5
tolerance: 0.01
weight: 2
- id: order-of-operations
prompt: What is 2 + 3 * 4?
number: 14
weight: 2
+94
View File
@@ -0,0 +1,94 @@
# Following a format: asked for JSON of a given shape, does the model give
# it — the thing an application parsing its answers depends on, and the
# first to go when a chat template drifts. Each answer is read as JSON (a
# code fence or a sentence around it is forgiven) and held to a schema.
#
# llmgw ctl eval MODEL --suite json-format
#
# Copy it into control.eval_dir (/etc/llmgw/eval) to change it; a suite
# there of the same name replaces this one.
name: json-format
description: Answers asked for as JSON of a given shape, held to a schema.
threshold: 0.7
system: You answer with a single JSON value and nothing else.
max_tokens: 200
cases:
- id: person
prompt: >-
Give a JSON object for a person named Ada Lovelace, born in 1815, with the
keys "name" (a string) and "born" (a number).
json_schema:
type: object
required: [name, born]
properties:
name: {type: string, pattern: "Ada"}
born: {type: integer, minimum: 1815, maximum: 1815}
- id: list-of-colours
prompt: Give a JSON array of exactly three colour names, as strings.
json_schema:
type: array
minItems: 3
maxItems: 3
items: {type: string, minLength: 1}
- id: boolean-answer
prompt: >-
Is water wet? Answer as a JSON object with one key, "answer", whose
value is true or false.
json_schema:
type: object
required: [answer]
properties:
answer: {type: boolean}
- id: nested
prompt: >-
Give a JSON object describing a book: "title" (a string), "author" (an
object with "first" and "last", both strings) and "pages" (a whole number
above zero). Use any book.
json_schema:
type: object
required: [title, author, pages]
properties:
title: {type: string, minLength: 1}
author:
type: object
required: [first, last]
properties:
first: {type: string}
last: {type: string}
pages: {type: integer, exclusiveMinimum: 0}
weight: 2
- id: enum-choice
prompt: >-
Classify the sentence "I love this product!" as positive, negative or
neutral. Answer as a JSON object {"sentiment": "..."} using one of those
three words in lower case.
json_schema:
type: object
required: [sentiment]
properties:
sentiment: {enum: [positive]}
weight: 2
- id: numbers
prompt: 'Give a JSON object {"sum": N} where N is the sum of 4, 5 and 6.'
json_schema:
type: object
required: [sum]
properties:
sum: {const: 15}
- id: no-extra-keys
prompt: >-
Give a JSON object with exactly two keys, "city" and "country", for
Paris. No other keys.
json_schema:
type: object
required: [city, country]
additionalProperties: false
properties:
city: {type: string}
country: {type: string}
+12 -13
View File
@@ -47,26 +47,24 @@ heartbeat_interval: 5s
reconnect_base: 1s
reconnect_max: 30s
# Fabric labelling is explicit and there is no way around it. OpenSM's default
# subnet prefix is fe80:: on every fabric, so a matching prefix proves nothing
# and a wrong guess produces a replica that starts, passes health checks and
# then hangs on its first all-reduce (plan §5.2).
# Each fabric this node's adapters are on, by name. The name is checked against
# what the adapters report — the subnet managers every port on one fabric sees —
# so two independent fabrics given one name are placed as two and the cluster
# page says so; give each its own. The rate is measured, not read from here.
#
# A node with no fabrics is its own single-node island: it can host single-node
# replicas and will never be given a multi-node one.
# replicas and will never be given a multi-node one. docs/fabrics.md has more.
fabrics:
- name: ib-a
hcas: [mlx5_0, mlx5_1, mlx5_2, mlx5_3]
rate_gbps: 200
discovery:
# Worth setting where the driver lives outside the default paths, which
# containerized driver installs often do.
nvidia_smi: ""
ib_root: /sys/class/infiniband
# NVML is the only source of NVLink topology, which is what keeps a
# tensor-parallel group off the PCIe path inside a node. It needs a binary
# built with `-tags nvml`; otherwise this silently falls back to nvidia-smi.
# NVLink topology comes from `nvidia-smi topo -m` on every build; a binary
# built with `-tags nvml` reads it from NVML instead, when this is set.
prefer_nvml: true
# Where this node keeps model weights besides /var/lib/llmgw-weights, which is
@@ -96,10 +94,11 @@ discovery:
engines:
vllm:
bin: /opt/venvs/vllm/bin/vllm
# Which multi-node mechanism works depends on the vLLM version, and the
# flags have changed more than once. Check the version you deploy.
launch_mode: ray # "ray" or "mp"
ray_bin: /opt/venvs/vllm/bin/ray
# How a replica spanning nodes is launched. mp, the default, is what vLLM
# documents today and needs nothing but vLLM; ray is for a vLLM too old
# for mp's multi-node flags, and needs Ray on every node.
launch_mode: mp # "mp" or "ray"
# ray_bin: /opt/venvs/vllm/bin/ray
sglang:
# SGLang is launched as a module, which is its documented entry point.
python: /opt/venvs/sglang/bin/python
+15 -2
View File
@@ -17,8 +17,10 @@ agent_listen: ":7443"
# Mutual TLS on the agent port (plan §5.6). With it, a node's identity is taken
# from its certificate's common name and an agent claiming a different node id
# is refused. Without it the port is plaintext HTTP/2 and anyone who can reach
# it can claim to be any node, so it must stay on a trusted network.
# is refused. Without it the port would be plaintext HTTP/2 and anyone who can
# reach it could claim to be any node, so the control plane refuses to start
# unless `agent_insecure: true` is set — and then listens on loopback unless
# agent_listen names an address. That is for one machine or a test.
#
# These certificates come from llmgw's own authority, not a public one: public
# authorities stopped issuing the clientAuth usage, so no Let's Encrypt
@@ -92,6 +94,11 @@ agent_listen: ":7443"
# flush_interval: 200ms
# batch_size: 500
# buffer_size: 8192
#
# # How long the record of each request is kept (what `ctl request` reads).
# # Charts, totals and budgets come from five-minute sums that are kept, so
# # this only bounds the table that grows by a row a request. 0 keeps all.
# usage_retention: 2160h # 90 days
router:
scrape_interval: 2s
@@ -152,6 +159,12 @@ router:
# back to a client that did not ask for it.
inject_usage: true
max_body_bytes: 33554432
# Memory for the answers of projects that reuse them (llmgw keys cache),
# all together; the least recently used go first. Nothing is reused until a
# project turns it on.
# cache:
# max_bytes: 134217728 # 128 MB
# max_entry_bytes: 1048576 # 1 MB
timeouts:
dial: 5s
+97 -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.
@@ -85,3 +110,63 @@ auth:
See [docs/install.md](../../docs/install.md) for moving an existing
installation onto PostgreSQL without reissuing every key.
## The store itself: PostgreSQL under Patroni
Two control planes on one PostgreSQL have moved the single point of failure,
not removed it. Patroni keeps PostgreSQL on two machines, one streaming to the
other, and promotes the standby when the primary is lost. It keeps its leader
lock in etcd, and etcd decides only with a majority — so three members, not
two: two machines that lose sight of each other must not both decide they are
the primary. The third member can be any machine that is up; it holds a vote,
not data.
On the reference cluster:
| machine | etcd | PostgreSQL |
|---|---|---|
| llmgw-ctl (control plane) | member | — |
| llmgw-pg01 | member | Patroni |
| llmgw-ctl02 (second control plane) | member | Patroni |
`llmgw server init --patroni user@host --patroni user@host` sets all of it up
over ssh and moves the store in — see [install.md](../../docs/install.md).
It looks before it acts at every step, so it is also how an existing cluster
is checked: run against one that is up, it changes nothing. What it writes
is in `etcd.default.example` (one member's `/etc/default/etcd`) and
`patroni.yml.example` (one node's `/etc/patroni/config.yml`), for anyone
doing it by hand; passwords are generated into `/root/llmgw-pg` on each
database machine.
**Give llmgw every member, and ask for the writable one:**
```
postgres://llmgw:…@10.0.0.149:5432,10.0.0.150:5432/llmgw?sslmode=disable&target_session_attrs=read-write
```
The driver connects to whichever member accepts writes, and reconnects to the
new one after a failover. No floating address or HAProxy is needed for that.
`llmgw server backup` reads the same address and dumps from a standby when
there is one.
Measured on the reference cluster, with the gateway serving throughout:
- **Planned switchover** (`patronictl switchover`): the control plane logged
one failed lease renewal, reconnected to the new primary, and kept its
leadership; nothing else noticed.
- **The primary's machine switched off**: the standby was promoted 24 seconds
later — the 30-second TTL in `patroni.yml` — and the control plane rode it
out without losing leadership or a replica. Started again, the old primary
rejoined as a standby by itself.
**Watching it.** For a store of several hosts the control plane reads the
cluster from each member's Patroni (port 8008) and publishes
`llmgw_store_member{member,host,role,state}`, `llmgw_store_replica_lag_bytes`
and `llmgw_store_patroni_up`; a host of the address that the cluster no longer
lists is a member in state `missing`. `llmgw ctl status` prints it as one
line, and `deploy/monitoring/llmgw-rules.yml` alerts on no leader, a member
not streaming or missing, and a standby too far behind to be promoted.
Not done here, and worth doing in production: a watchdog (`softdog`) so a
primary that has lost the lock but not died cannot keep accepting writes, TLS
on etcd and between PostgreSQL members, and more than one standby.
+10
View File
@@ -0,0 +1,10 @@
# llmgw: one member of the three that hold Patroni's leader lock (140, 149, 150).
ETCD_NAME=llmgw-pg01
ETCD_DATA_DIR=/var/lib/etcd/default
ETCD_LISTEN_PEER_URLS=http://10.0.0.149:2380
ETCD_LISTEN_CLIENT_URLS=http://10.0.0.149:2379,http://127.0.0.1:2379
ETCD_INITIAL_ADVERTISE_PEER_URLS=http://10.0.0.149:2380
ETCD_ADVERTISE_CLIENT_URLS=http://10.0.0.149:2379
ETCD_INITIAL_CLUSTER=llmgw-ctl=http://10.0.0.140:2380,llmgw-pg01=http://10.0.0.149:2380,llmgw-ctl02=http://10.0.0.150:2380
ETCD_INITIAL_CLUSTER_STATE=new
ETCD_INITIAL_CLUSTER_TOKEN=llmgw-patroni
+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.
+68
View File
@@ -0,0 +1,68 @@
# llmgw: the control planes' store, kept by Patroni on llmgw-pg01 and
# llmgw-ctl02, with the leader lock in etcd on 140, 149 and 150.
scope: llmgw
namespace: /service/
name: llmgw-pg01
restapi:
listen: 10.0.0.149:8008
connect_address: 10.0.0.149:8008
etcd3:
hosts: 10.0.0.140:2379,10.0.0.149:2379,10.0.0.150:2379
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
maximum_lag_on_failover: 1048576
postgresql:
use_pg_rewind: true
use_slots: true
parameters:
wal_level: replica
hot_standby: "on"
max_wal_senders: 10
max_replication_slots: 10
wal_keep_size: 1GB
wal_log_hints: "on"
password_encryption: scram-sha-256
initdb:
- encoding: UTF8
- data-checksums
postgresql:
listen: 127.0.0.1,10.0.0.149:5432
connect_address: 10.0.0.149:5432
data_dir: /var/lib/postgresql/16/llmgw
bin_dir: /usr/lib/postgresql/16/bin
pgpass: /var/lib/postgresql/.pgpass_patroni
authentication:
superuser:
username: postgres
password: "<from /root/llmgw-pg>"
replication:
username: replicator
password: "<from /root/llmgw-pg>"
rewind:
username: postgres
password: "<from /root/llmgw-pg>"
parameters:
unix_socket_directories: /var/run/postgresql
pg_hba:
- local all all peer
- host all all 127.0.0.1/32 scram-sha-256
- host replication replicator 10.0.0.149/32 scram-sha-256
- host replication replicator 10.0.0.150/32 scram-sha-256
- host all postgres 10.0.0.149/32 scram-sha-256
- host all postgres 10.0.0.150/32 scram-sha-256
- host llmgw llmgw 10.0.0.140/32 scram-sha-256
- host llmgw llmgw 10.0.0.149/32 scram-sha-256
- host llmgw llmgw 10.0.0.150/32 scram-sha-256
tags:
nofailover: false
noloadbalance: false
clonefrom: false
nosync: false
+17
View File
@@ -0,0 +1,17 @@
apiVersion: v2
name: llmgw
description: >-
The llmgw control plane — the OpenAI-compatible gateway, the router and the
scheduler that places models on GPU and CPU nodes. The nodes themselves run
outside Kubernetes; see docs/kubernetes.md.
type: application
# The chart's own version; appVersion is the llmgw release the image holds.
version: 0.1.0
appVersion: "v0.11.1"
home: https://gitea.mynodes.xyz/ilker/llmgw
sources:
- https://gitea.mynodes.xyz/ilker/llmgw
keywords:
- llm
- gateway
- inference
+27
View File
@@ -0,0 +1,27 @@
The llmgw control plane is starting as {{ include "llmgw.fullname" . }}.
The admin API listens on the pod's loopback only. To reach it from here:
kubectl -n {{ .Release.Namespace }} port-forward statefulset/{{ include "llmgw.fullname" . }} 9090:9090 &
export LLMGW_ADMIN_TOKEN=$(kubectl -n {{ .Release.Namespace }} get secret {{ include "llmgw.adminSecret" . }} \
-o jsonpath='{.data.{{ if .Values.adminToken.existingSecret }}{{ .Values.adminToken.secretKey }}{{ else }}admin-token{{ end }}}' | base64 -d)
llmgw ctl status
The first administrator:
kubectl -n {{ .Release.Namespace }} exec -it statefulset/{{ include "llmgw.fullname" . }} -- \
llmgw users create you@example.com --password '<a password>'
kubectl -n {{ .Release.Namespace }} exec -it statefulset/{{ include "llmgw.fullname" . }} -- \
llmgw users role you@example.com admin
Agents connect to the {{ include "llmgw.fullname" . }}-agents Service on port {{ .Values.agentService.port }}:
kubectl -n {{ .Release.Namespace }} get service {{ include "llmgw.fullname" . }}-agents
Its address must be in the certificate in {{ .Values.agentTLS.existingSecret }}. Nodes join from
the machine holding llmgw's authority:
sudo llmgw node add ubuntu@gpu01 --server https://<that address>:{{ .Values.agentService.port }} \
--pki ./pki --admin 127.0.0.1:9090
docs/kubernetes.md has the rest, and why the nodes stay outside Kubernetes.
+74
View File
@@ -0,0 +1,74 @@
{{/* The release's name for everything the chart makes. */}}
{{- define "llmgw.fullname" -}}
{{- if contains .Chart.Name .Release.Name -}}
{{- .Release.Name | trunc 63 | trimSuffix "-" -}}
{{- else -}}
{{- printf "%s-%s" .Release.Name .Chart.Name | trunc 63 | trimSuffix "-" -}}
{{- end -}}
{{- end -}}
{{- define "llmgw.selectorLabels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/instance: {{ .Release.Name }}
{{- end -}}
{{- define "llmgw.labels" -}}
{{ include "llmgw.selectorLabels" . }}
app.kubernetes.io/version: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
app.kubernetes.io/managed-by: {{ .Release.Service }}
helm.sh/chart: {{ printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" }}
{{- end -}}
{{- define "llmgw.image" -}}
{{- if not .Values.image.repository -}}
{{- fail "image.repository: the image built from deploy/container/Dockerfile, pushed where the cluster can pull it (the project publishes binaries, not an image)" -}}
{{- end -}}
{{- printf "%s:%s" .Values.image.repository (.Values.image.tag | default .Chart.AppVersion) -}}
{{- end -}}
{{- define "llmgw.adminSecret" -}}
{{- .Values.adminToken.existingSecret | default (printf "%s-admin" (include "llmgw.fullname" .)) -}}
{{- end -}}
{{/*
server.yaml: what the chart decides — listeners, where the certificates,
the token and the store are mounted — laid over .Values.config. The chart's
keys win, because each names a port or a path the pod is built around.
*/}}
{{- define "llmgw.serverConfig" -}}
{{- $store := "/var/lib/llmgw/llmgw.db" -}}
{{- if eq .Values.store.type "postgres" -}}
{{- $store = "@/etc/llmgw/store/dsn" -}}
{{- end -}}
{{- $base := dict
"listen" ":8080"
"admin_listen" "127.0.0.1:9090"
"metrics_listen" ":9091"
"agent_listen" ":7443"
"admin_token_file" "/etc/llmgw/admin/admin-token"
"agent_tls" (dict "ca" "/etc/llmgw/tls/ca.pem" "cert" "/etc/llmgw/tls/server.pem" "key" "/etc/llmgw/tls/server-key.pem")
"auth" (dict "store" $store)
-}}
{{- if .Values.offlineChecksums.configMap -}}
{{- $_ := set $base "offline" (dict "checksums" "/etc/llmgw/offline/offline.sha256") -}}
{{- end -}}
{{- $cfg := mustMergeOverwrite (deepCopy (.Values.config | default dict)) $base -}}
# Made by the llmgw Helm chart from its values. Every key: docs/reference.md
{{ toYaml $cfg }}
{{- end -}}
{{/* Refuses values that would make a pod that cannot work. */}}
{{- define "llmgw.validate" -}}
{{- if not (has .Values.store.type (list "sqlite" "postgres")) -}}
{{- fail (printf "store.type %q: sqlite or postgres" .Values.store.type) -}}
{{- end -}}
{{- if ne (int .Values.replicas) 1 -}}
{{- fail "replicas: this chart runs one control plane; values.yaml says why" -}}
{{- end -}}
{{- if and (eq .Values.store.type "postgres") (not .Values.store.postgres.existingSecret) -}}
{{- fail "store.postgres.existingSecret: a Secret holding the PostgreSQL address" -}}
{{- end -}}
{{- if not .Values.agentTLS.existingSecret -}}
{{- fail "agentTLS.existingSecret: a Secret with ca.pem, server.pem and server-key.pem from llmgw's authority (see values.yaml)" -}}
{{- end -}}
{{- end -}}
@@ -0,0 +1,10 @@
{{- include "llmgw.validate" . -}}
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "llmgw.fullname" . }}
labels:
{{- include "llmgw.labels" . | nindent 4 }}
data:
server.yaml: |
{{- include "llmgw.serverConfig" . | nindent 4 }}
+37
View File
@@ -0,0 +1,37 @@
{{- if .Values.ingress.enabled }}
# The OpenAI-compatible API, the portal and the chat page on port 8080. Never
# the agent port: its mutual TLS cannot pass through an HTTP proxy.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ include "llmgw.fullname" . }}
labels:
{{- include "llmgw.labels" . | nindent 4 }}
{{- with .Values.ingress.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
{{- with .Values.ingress.className }}
ingressClassName: {{ . }}
{{- end }}
{{- with .Values.ingress.tls }}
tls:
{{- toYaml . | nindent 4 }}
{{- end }}
rules:
{{- range .Values.ingress.hosts }}
- host: {{ .host | quote }}
http:
paths:
{{- range .paths }}
- path: {{ .path }}
pathType: {{ .pathType }}
backend:
service:
name: {{ include "llmgw.fullname" $ }}
port:
name: api
{{- end }}
{{- end }}
{{- end }}
@@ -0,0 +1,19 @@
{{- if not .Values.adminToken.existingSecret }}
{{- $name := include "llmgw.adminSecret" . }}
{{- $token := randAlphaNum 64 }}
{{- /* Kept across upgrades: a new token on every upgrade would lock out every console holding the old one. */}}
{{- with lookup "v1" "Secret" .Release.Namespace $name }}
{{- $token = index .data "admin-token" | b64dec }}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
name: {{ $name }}
labels:
{{- include "llmgw.labels" . | nindent 4 }}
annotations:
helm.sh/resource-policy: keep
type: Opaque
data:
admin-token: {{ $token | b64enc | quote }}
{{- end }}
@@ -0,0 +1,21 @@
{{- if .Values.serviceMonitor.enabled }}
# For the Prometheus Operator: the metrics port, which serves metrics and
# nothing else. deploy/monitoring holds the rules and dashboards that read them.
apiVersion: monitoring.coreos.com/v1
kind: ServiceMonitor
metadata:
name: {{ include "llmgw.fullname" . }}
labels:
{{- include "llmgw.labels" . | nindent 4 }}
{{- with .Values.serviceMonitor.labels }}
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
selector:
matchLabels:
{{- include "llmgw.selectorLabels" . | nindent 6 }}
endpoints:
- port: metrics
path: /metrics
interval: {{ .Values.serviceMonitor.interval }}
{{- end }}
+70
View File
@@ -0,0 +1,70 @@
# The API and the metrics, for the cluster and an Ingress.
apiVersion: v1
kind: Service
metadata:
name: {{ include "llmgw.fullname" . }}
labels:
{{- include "llmgw.labels" . | nindent 4 }}
{{- with .Values.service.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.service.type }}
selector:
{{- include "llmgw.selectorLabels" . | nindent 4 }}
ports:
- name: api
port: {{ .Values.service.port }}
targetPort: api
- name: metrics
port: {{ .Values.service.metricsPort }}
targetPort: metrics
---
# Where agents on the nodes connect. Mutual TLS ends in the pod: whatever is
# in between must pass TCP through untouched.
apiVersion: v1
kind: Service
metadata:
name: {{ include "llmgw.fullname" . }}-agents
labels:
{{- include "llmgw.labels" . | nindent 4 }}
{{- with .Values.agentService.annotations }}
annotations:
{{- toYaml . | nindent 4 }}
{{- end }}
spec:
type: {{ .Values.agentService.type }}
{{- if and (eq .Values.agentService.type "LoadBalancer") .Values.agentService.loadBalancerIP }}
loadBalancerIP: {{ .Values.agentService.loadBalancerIP }}
{{- end }}
{{- with .Values.agentService.loadBalancerSourceRanges }}
loadBalancerSourceRanges:
{{- toYaml . | nindent 4 }}
{{- end }}
{{- if has .Values.agentService.type (list "LoadBalancer" "NodePort") }}
externalTrafficPolicy: {{ .Values.agentService.externalTrafficPolicy }}
{{- end }}
selector:
{{- include "llmgw.selectorLabels" . | nindent 4 }}
ports:
- name: agents
port: {{ .Values.agentService.port }}
targetPort: agents
---
# The StatefulSet's own, giving each pod a stable name.
apiVersion: v1
kind: Service
metadata:
name: {{ include "llmgw.fullname" . }}-headless
labels:
{{- include "llmgw.labels" . | nindent 4 }}
spec:
clusterIP: None
publishNotReadyAddresses: true
selector:
{{- include "llmgw.selectorLabels" . | nindent 4 }}
ports:
- name: api
port: 8080
targetPort: api
@@ -0,0 +1,181 @@
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: {{ include "llmgw.fullname" . }}
labels:
{{- include "llmgw.labels" . | nindent 4 }}
spec:
# One control plane: Kubernetes replaces it within seconds when it goes,
# and the agents reconnect on their own. See replicas in values.yaml.
replicas: 1
serviceName: {{ include "llmgw.fullname" . }}-headless
selector:
matchLabels:
{{- include "llmgw.selectorLabels" . | nindent 6 }}
template:
metadata:
labels:
{{- include "llmgw.selectorLabels" . | nindent 8 }}
{{- with .Values.podLabels }}
{{- toYaml . | nindent 8 }}
{{- end }}
annotations:
# server.yaml is read at start: a changed one restarts the pod.
checksum/config: {{ include "llmgw.serverConfig" . | sha256sum }}
{{- with .Values.podAnnotations }}
{{- toYaml . | nindent 8 }}
{{- end }}
spec:
{{- with .Values.imagePullSecrets }}
imagePullSecrets:
{{- toYaml . | nindent 8 }}
{{- end }}
# Agents reconnect within seconds of a control plane going; a long
# grace period only delays the one that replaces it.
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 65532
runAsGroup: 65532
fsGroup: 65532
seccompProfile:
type: RuntimeDefault
containers:
- name: llmgw
image: {{ include "llmgw.image" . }}
imagePullPolicy: {{ .Values.image.pullPolicy }}
args: ["server", "--config", "/etc/llmgw/server.yaml"]
env:
# For `kubectl exec ... -- llmgw ctl`, which reads the token
# from here; the server reads admin_token_file.
- name: LLMGW_ADMIN_TOKEN
valueFrom:
secretKeyRef:
name: {{ include "llmgw.adminSecret" . }}
key: {{ if .Values.adminToken.existingSecret }}{{ .Values.adminToken.secretKey }}{{ else }}admin-token{{ end }}
ports:
- name: api
containerPort: 8080
- name: agents
containerPort: 7443
- name: metrics
containerPort: 9091
# Up is /health. Ready is /leader: a control plane holds the
# agents' connections and places models only once it leads — on
# PostgreSQL, once the lease of the one it replaced has run out. A
# cluster with no node yet is still ready: /ready would wait for a
# replica, and nodes could then never connect.
startupProbe:
httpGet: {path: /health, port: api}
periodSeconds: 5
failureThreshold: 60
livenessProbe:
httpGet: {path: /health, port: api}
periodSeconds: 10
failureThreshold: 6
readinessProbe:
httpGet: {path: /leader, port: api}
periodSeconds: 5
failureThreshold: 2
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
resources:
{{- toYaml .Values.resources | nindent 12 }}
volumeMounts:
- name: config
mountPath: /etc/llmgw/server.yaml
subPath: server.yaml
readOnly: true
- name: agent-tls
mountPath: /etc/llmgw/tls
readOnly: true
- name: admin-token
mountPath: /etc/llmgw/admin
readOnly: true
{{- if eq .Values.store.type "postgres" }}
- name: store
mountPath: /etc/llmgw/store
readOnly: true
{{- end }}
{{- if .Values.offlineChecksums.configMap }}
- name: offline
mountPath: /etc/llmgw/offline
readOnly: true
{{- end }}
- name: data
mountPath: /var/lib/llmgw
- name: tmp
mountPath: /tmp
volumes:
- name: config
configMap:
name: {{ include "llmgw.fullname" . }}
- name: agent-tls
secret:
secretName: {{ .Values.agentTLS.existingSecret }}
defaultMode: 0440
items:
- {key: ca.pem, path: ca.pem}
- {key: server.pem, path: server.pem}
- {key: server-key.pem, path: server-key.pem}
- name: admin-token
secret:
secretName: {{ include "llmgw.adminSecret" . }}
defaultMode: 0440
items:
- key: {{ if .Values.adminToken.existingSecret }}{{ .Values.adminToken.secretKey }}{{ else }}admin-token{{ end }}
path: admin-token
{{- if eq .Values.store.type "postgres" }}
- name: store
secret:
secretName: {{ .Values.store.postgres.existingSecret }}
defaultMode: 0440
items:
- key: {{ .Values.store.postgres.secretKey }}
path: dsn
{{- end }}
{{- if .Values.offlineChecksums.configMap }}
- name: offline
configMap:
name: {{ .Values.offlineChecksums.configMap }}
items:
- key: {{ .Values.offlineChecksums.key }}
path: offline.sha256
{{- end }}
- name: tmp
emptyDir: {}
{{- if eq .Values.store.type "postgres" }}
# The store is PostgreSQL; nothing here outlives the pod.
- name: data
emptyDir: {}
{{- end }}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.affinity }}
affinity:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- if eq .Values.store.type "sqlite" }}
volumeClaimTemplates:
- metadata:
name: data
labels:
{{- include "llmgw.selectorLabels" . | nindent 10 }}
spec:
accessModes: ["ReadWriteOnce"]
{{- with .Values.store.sqlite.storageClass }}
storageClassName: {{ . }}
{{- end }}
resources:
requests:
storage: {{ .Values.store.sqlite.size }}
{{- end }}
+132
View File
@@ -0,0 +1,132 @@
# The llmgw control plane in Kubernetes. docs/kubernetes.md says what this
# runs, what it does not, and how nodes outside the cluster join it.
image:
# Where you pushed the image built from deploy/container/Dockerfile. The
# project publishes binaries, not an image, so this has no default.
repository: ""
# Empty takes the chart's appVersion.
tag: ""
pullPolicy: IfNotPresent
imagePullSecrets: []
# One control plane, always. Two against PostgreSQL would elect a leader and
# keep a standby, but a standby serves nothing and is never ready, so a
# rollout would wait on it for ever. Until the chart handles that, it runs
# one: Kubernetes replaces it within seconds when it goes, and the agents
# reconnect on their own.
replicas: 1
store:
# sqlite: a file on a PersistentVolumeClaim, for one replica.
# postgres: a PostgreSQL you run, its address in a Secret.
type: sqlite
sqlite:
size: 10Gi
storageClass: ""
postgres:
# A Secret holding the address, postgres://user:password@host:5432/llmgw?sslmode=verify-full
existingSecret: ""
secretKey: dsn
# The agent port's certificate. It comes from llmgw's own authority, not a
# public one — agents present certificates from the same authority — so it
# is made outside the cluster, where the authority's key stays:
#
# llmgw ctl cert init-ca --dir ./pki
# llmgw ctl cert issue-server llmgw-ctl --host <agent address> --dir ./pki --out ./pki
# kubectl create secret generic llmgw-agent-tls \
# --from-file=ca.pem=pki/ca.pem \
# --from-file=server.pem=pki/server.pem \
# --from-file=server-key.pem=pki/server-key.pem
#
# --host is every address agents dial: agentService's load balancer address
# or DNS name.
agentTLS:
existingSecret: ""
# The admin API's token (llmgw ctl, the console). Empty makes one, kept in a
# Secret across upgrades.
adminToken:
existingSecret: ""
secretKey: admin-token
# server.yaml beyond what the chart sets (listeners, TLS, store, admin
# token): any key of docs/reference.md, laid over the chart's. An offline
# cluster sets offline.mirror here, and offlineChecksums below.
config:
control:
enabled: true
auth:
enabled: true
signup_open: false
default_rpm: 60
default_tpm: 200000
default_price:
prompt_per_1k: 1000
completion_per_1k: 1000
max_key_ttl: 2160h
# mirror:
# api: https://models.internal:8082
# offline:
# mirror: http://models.internal:8081
# firewall_networks: ["10.42.0.0/16"] # the pods' network, so nodes' engines answer the router
# On an offline cluster, a ConfigMap or Secret holding the engine checksums
# 'llmgw bundle trust' writes (offline.sha256), mounted where offline.checksums
# points. Empty mounts none.
offlineChecksums:
configMap: ""
key: offline.sha256
# The OpenAI-compatible API and the metrics, inside the cluster.
service:
type: ClusterIP
port: 8080
metricsPort: 9091
annotations: {}
# Where agents connect, with mutual TLS. Nodes run outside the cluster, so
# this is a load balancer by default; the TLS ends in the pod, never at a
# proxy in between.
agentService:
type: LoadBalancer
port: 7443
annotations: {}
loadBalancerIP: ""
loadBalancerSourceRanges: []
# Keeps the agents' addresses as they are, which the node's identity does
# not need but the logs and the audit read better with.
externalTrafficPolicy: Local
ingress:
enabled: false
className: ""
annotations: {}
hosts:
- host: llm.example.com
paths:
- path: /
pathType: Prefix
tls: []
# - secretName: llm-example-com
# hosts: [llm.example.com]
# A ServiceMonitor for the Prometheus Operator, scraping the metrics port.
serviceMonitor:
enabled: false
interval: 30s
labels: {}
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
memory: 2Gi
nodeSelector: {}
tolerations: []
affinity: {}
podAnnotations: {}
podLabels: {}
+12 -1
View File
@@ -25,6 +25,13 @@ An HTTP server serving a directory. That is the whole design.
model.safetensors
```
At an offline site the mirror also holds what the nodes would otherwise fetch
from the internet — llmgw's releases and the llama.cpp builds — under
`_llmgw/releases/` and `_llmgw/engines/`, put there by `llmgw bundle import`
(docs/install.md, "Offline sites"). They are not models: `mirror list` and
`mirror unused` pass over them, and a node checks each against what the
control plane says it must be, not against anything the mirror says.
The date in the path is the point of the layout, not decoration. A model
repository that changes upstream changes what you are running, and the first
sign is a prompt that worked last week answering differently this week with
@@ -65,9 +72,13 @@ hand it certificates:
llmgw mirror serve --dir /srv/models --base http://models.internal:8081 \
--tls-ca /etc/llmgw/ca.pem \
--tls-cert /etc/llmgw/mirror.pem \
--tls-key /etc/llmgw/mirror-key.pem
--tls-key /etc/llmgw/mirror-key.pem \
--api-client control-plane
```
`--api-client` is required with `--tls-ca`: it names the one certificate the
API answers, because the CA signs every node's certificate as well.
That is the same CA the agents already use, so the certificates come from the
authority you have:
+18 -2
View File
@@ -46,6 +46,19 @@ apt-get install prometheus prometheus-alertmanager
install -m 0644 prometheus.yml /etc/prometheus/prometheus.yml
install -m 0644 llmgw-rules.yml /etc/prometheus/rules/llmgw.yml
install -m 0644 alertmanager.yml /etc/prometheus/alertmanager.yml
# Alertmanager answers on loopback only. Its API takes silences from anyone who
# can reach it, with no login, and silencing BackupTooOld or ModelParked is how
# something that should be noticed is not. Prometheus beside it is its only
# client; one Alertmanager needs no cluster port.
echo 'ARGS="--web.listen-address=127.0.0.1:9093 --cluster.listen-address="' \
> /etc/default/prometheus-alertmanager
# Prometheus itself is read-only as installed: its admin and lifecycle APIs
# stay off (no --web.enable-admin-api, no --web.enable-lifecycle). It still
# answers any query from anyone who reaches :9090, which says which models and
# nodes there are and how busy each is. Its readers are the control plane
# (monitoring.prometheus) and Grafana beside it; to keep it to those, allow
# the control plane and drop the rest, e.g. with nftables:
# nft add rule inet filter input tcp dport 9090 ip saddr != { 127.0.0.1, CONTROL_PLANE_IP } drop
promtool check config /etc/prometheus/prometheus.yml
systemctl restart prometheus prometheus-alertmanager
@@ -53,6 +66,8 @@ install -m 0644 grafana/datasource.yaml /etc/grafana/provisioning/datasources/
install -m 0644 grafana/dashboards.yaml /etc/grafana/provisioning/dashboards/llmgw.yaml
install -m 0644 -o grafana -g grafana grafana/llmgw-overview.json \
/var/lib/grafana/dashboards/llmgw.json
install -m 0644 -o grafana -g grafana grafana/llmgw-tenants.json \
/var/lib/grafana/dashboards/llmgw-tenants.json
systemctl restart grafana-server
```
@@ -75,7 +90,8 @@ Alertmanager groups and holds alerts and has no receiver.
Where an alert should go — an address, a Slack workspace, a webhook — is a
decision with credentials attached, and a monitoring system that quietly starts
mailing people is worse than one that is visibly not wired up. Alerts are
visible at `:9093` and in Prometheus; add a receiver to `alertmanager.yml` to
visible in Prometheus, and at `:9093` on the monitoring host itself (`ssh -L
9093:127.0.0.1:9093 <host>` to open its page); add a receiver to `alertmanager.yml` to
make them travel. `critical` already routes separately so it can be pointed
somewhere else without touching the rest.
@@ -86,7 +102,7 @@ An alerting system nobody has seen fire is an assumption.
```
llmgw ctl cordon <node> # work already there keeps running
# ~2 minutes later
curl -s http://<host>:9093/api/v2/alerts | jq '.[].labels'
ssh <host> curl -s http://127.0.0.1:9093/api/v2/alerts | jq '.[].labels'
llmgw ctl uncordon <node> # the alert resolves on its own
```
+50 -2
View File
@@ -1,10 +1,11 @@
# Alertmanager for the llmgw cluster.
#
# Nothing is sent anywhere yet, on purpose. Where an alert should go — an
# Nothing is sent anywhere until a receiver is chosen, on purpose. Where an alert should go — an
# address, a Slack workspace, a webhook — is a decision with credentials
# attached, and a monitoring system that quietly starts mailing people is worse
# than one that is visibly not wired up. Alerts group and fire here and are
# visible at :9093 and in Prometheus; add a receiver below to make them travel.
# visible at :9093 and in Prometheus; uncomment a receiver template below and
# name it on the route to make them travel.
global:
resolve_timeout: 5m
@@ -31,6 +32,53 @@ route:
receivers:
- name: none
# Receiver templates. Uncomment one, fill in where it goes, and name it in
# `receiver:` above — on the route, the critical branch, or both. Secrets go
# in files Alertmanager reads (the *_file settings), mode 0640 owned by
# root:prometheus, not in this file. `amtool check-config` before a restart.
#
# Slack (an incoming webhook):
# - name: slack
# slack_configs:
# - api_url_file: /etc/prometheus/slack-webhook-url
# channel: '#llmgw'
# send_resolved: true
# title: '{{ .Status | toUpper }}: {{ .CommonLabels.alertname }}'
# text: >-
# {{ range .Alerts }}{{ .Annotations.summary }}
# {{ .Annotations.description }}
# {{ end }}
#
# E-mail (through a relay that takes authenticated submission):
# - name: email
# email_configs:
# - to: ops@example.com
# from: llmgw-alerts@example.com
# smarthost: smtp.example.com:587
# auth_username: llmgw-alerts@example.com
# auth_password_file: /etc/prometheus/smtp-password
# send_resolved: true
#
# PagerDuty (Events API v2), for the critical branch — what should wake
# someone:
# - name: pagerduty
# pagerduty_configs:
# - routing_key_file: /etc/prometheus/pagerduty-routing-key
# severity: '{{ .CommonLabels.severity }}'
# description: '{{ .CommonAnnotations.summary }}'
#
# A webhook of your own (Alertmanager's JSON, version 4):
# - name: webhook
# webhook_configs:
# - url_file: /etc/prometheus/alert-webhook-url
# send_resolved: true
# max_alerts: 20
#
# The gateway itself also sends a few events straight to webhooks, with no
# Prometheus in between — a model parked, a node lost, a budget spent, new
# weights published: `notify.webhooks` in server.yaml (docs/install.md,
# "Being told").
inhibit_rules:
# A node that is down explains every replica on it. Reporting both says the
# same thing twice and buries whichever is the actual cause.
@@ -487,6 +487,472 @@
"defaults": {},
"overrides": []
}
},
{
"type": "row",
"title": "GPUs",
"id": 14,
"gridPos": {
"x": 0,
"y": 30,
"w": 24,
"h": 1
},
"collapsed": false,
"panels": []
},
{
"type": "timeseries",
"title": "GPU busy, by card",
"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"
},
"gridPos": {
"x": 0,
"y": 31,
"w": 12,
"h": 7
},
"id": 15,
"targets": [
{
"expr": "llmgw_gpu_utilization_percent",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU memory in use, by card",
"description": "Framebuffer in use. Engines reserve most of it at start.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 12,
"y": 31,
"w": 12,
"h": 7
},
"id": 16,
"targets": [
{
"expr": "llmgw_gpu_memory_used_bytes / llmgw_gpu_memory_total_bytes",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU memory bandwidth busy, by card",
"description": "Decode is bound by memory: high here with compute low is a card waiting on memory.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 0,
"y": 38,
"w": 12,
"h": 7
},
"id": 17,
"targets": [
{
"expr": "llmgw_gpu_memory_utilization_percent",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"min": 0,
"max": 100
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU temperature",
"description": "Throttling usually starts in the high 80s.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 12,
"y": 38,
"w": 12,
"h": 7
},
"id": 18,
"targets": [
{
"expr": "llmgw_gpu_temperature_celsius",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "celsius"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU power against its limit",
"description": "1 is the enforced limit; a card held there is power-capped.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 0,
"y": 45,
"w": 12,
"h": 7
},
"id": 19,
"targets": [
{
"expr": "llmgw_gpu_power_watts / llmgw_gpu_power_limit_watts",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1.1
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU clock",
"description": "A clock held low under load is the card throttled; see the next panel for why.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 12,
"y": 45,
"w": 12,
"h": 7
},
"id": 20,
"targets": [
{
"expr": "llmgw_gpu_sm_clock_mhz",
"legendFormat": "{{node}} gpu {{gpu}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "rotmhz"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "What holds GPUs back",
"description": "Cards throttled now, by reason, and uncorrected ECC errors since the driver loaded.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 0,
"y": 52,
"w": 24,
"h": 7
},
"id": 21,
"targets": [
{
"expr": "sum by (reason) (llmgw_gpu_throttled)",
"legendFormat": "throttled: {{reason}}",
"refId": "A"
},
{
"expr": "sum by (node, gpu) (llmgw_gpu_ecc_errors)",
"legendFormat": "ECC errors {{node}} gpu {{gpu}}",
"refId": "B"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "Output tokens/s per GPU, by model",
"description": "What each model gets out of each card it holds.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 0,
"y": 59,
"w": 12,
"h": 7
},
"id": 22,
"targets": [
{
"expr": "sum by (model) (rate(llmgw_tokens_total{direction=\"completion\"}[5m])) / count by (model) (llmgw_gpu_assigned)",
"legendFormat": "{{model}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "Output tokens per watt-hour, by model",
"description": "Tokens for the energy the model's cards draw.",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"gridPos": {
"x": 12,
"y": 59,
"w": 12,
"h": 7
},
"id": 23,
"targets": [
{
"expr": "sum by (model) (rate(llmgw_tokens_total{direction=\"completion\"}[5m])) * 3600 / sum by (model) (llmgw_gpu_power_watts * on (node, gpu) group_left (model) llmgw_gpu_assigned)",
"legendFormat": "{{model}}",
"refId": "A"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"mode": "multi"
}
}
},
{
"type": "timeseries",
"title": "GPU wear",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"id": 24,
"description": "What wears cards out: XID errors in the last 15 minutes, memory rows remapped, PCIe retries an hour. Anything above zero here is worth a look on that card's node page.",
"gridPos": {
"x": 0,
"y": 66,
"w": 24,
"h": 7
},
"targets": [
{
"expr": "increase(llmgw_gpu_xid_errors[15m]) > 0",
"legendFormat": "XID {{node}} gpu {{gpu}}",
"refId": "A"
},
{
"expr": "sum by (node, gpu) (llmgw_gpu_remapped_rows) > 0",
"legendFormat": "rows remapped {{node}} gpu {{gpu}}",
"refId": "B"
},
{
"expr": "increase(llmgw_gpu_pcie_replays[1h]) > 0",
"legendFormat": "PCIe retries/h {{node}} gpu {{gpu}}",
"refId": "C"
},
{
"expr": "increase(llmgw_gpu_nvlink_errors[1h]) > 0",
"legendFormat": "NVLink {{kind}}/h {{node}} gpu {{gpu}}",
"refId": "D"
}
],
"fieldConfig": {
"defaults": {
"unit": "short"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "list",
"placement": "bottom"
},
"tooltip": {
"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"
}
}
}
]
}
@@ -0,0 +1,650 @@
{
"uid": "llmgw-tenants",
"title": "llmgw \u2014 tenants",
"schemaVersion": 39,
"version": 1,
"editable": true,
"time": {
"from": "now-24h",
"to": "now"
},
"refresh": "1m",
"tags": [
"llmgw"
],
"description": "Requests, tokens, failures, refusals and first-token time per tenant \u2014 the level router.tenant_label names \u2014 and every budget's share spent.",
"templating": {
"list": [
{
"name": "tenant",
"label": "Tenant",
"type": "query",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"query": {
"query": "label_values(llmgw_tenant_requests_total, tenant)",
"refId": "tenant"
},
"definition": "label_values(llmgw_tenant_requests_total, tenant)",
"multi": true,
"includeAll": true,
"allValue": ".*",
"current": {
"text": "All",
"value": "$__all"
},
"refresh": 2,
"sort": 1
}
]
},
"panels": [
{
"id": 1,
"title": "Requests a second",
"type": "stat",
"description": "Across the tenants chosen above.",
"gridPos": {
"x": 0,
"y": 0,
"w": 6,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps"
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "auto",
"colorMode": "value"
}
},
{
"id": 2,
"title": "Failing",
"type": "stat",
"description": "Requests that failed, not counting refusals by a limit.",
"gridPos": {
"x": 6,
"y": 0,
"w": 6,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(llmgw_tenant_requests_total{tenant=~\"$tenant\",outcome=\"error\"}[5m])) / sum(rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 0.02
},
{
"color": "red",
"value": 0.05
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "auto",
"colorMode": "value"
}
},
{
"id": 3,
"title": "Refused by a limit",
"type": "stat",
"description": "Rate, token and budget limits; 429 to the caller.",
"gridPos": {
"x": 12,
"y": 0,
"w": 6,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum(rate(llmgw_tenant_requests_total{tenant=~\"$tenant\",outcome=\"limited\"}[5m])) / sum(rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 0.02
},
{
"color": "red",
"value": 0.1
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "auto",
"colorMode": "value"
}
},
{
"id": 4,
"title": "First token, p95",
"type": "stat",
"description": "What people wait for before anything appears.",
"gridPos": {
"x": 18,
"y": 0,
"w": 6,
"h": 4
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum by (le) (rate(llmgw_tenant_ttft_seconds_bucket{tenant=~\"$tenant\"}[5m])))",
"legendFormat": "",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 3
},
{
"color": "red",
"value": 10
}
]
}
},
"overrides": []
},
"options": {
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"textMode": "auto",
"colorMode": "value"
}
},
{
"id": 5,
"title": "Requests by tenant",
"type": "timeseries",
"description": "Stacked, so the top edge is the whole of it.",
"gridPos": {
"x": 0,
"y": 4,
"w": 12,
"h": 9
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum by (tenant) (rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "{{tenant}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "reqps",
"custom": {
"stacking": {
"mode": "normal"
},
"fillOpacity": 30
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 6,
"title": "Tokens by tenant",
"type": "timeseries",
"description": "Prompt and completion together: what was consumed.",
"gridPos": {
"x": 12,
"y": 4,
"w": 12,
"h": 9
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum by (tenant) (rate(llmgw_tenant_tokens_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "{{tenant}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "short",
"custom": {
"stacking": {
"mode": "normal"
},
"fillOpacity": 30
}
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 7,
"title": "Refused by a limit, by tenant",
"type": "timeseries",
"description": "The share of each tenant's requests a limit turned away.",
"gridPos": {
"x": 0,
"y": 13,
"w": 12,
"h": 8
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum by (tenant) (rate(llmgw_tenant_requests_total{tenant=~\"$tenant\",outcome=\"limited\"}[5m])) / sum by (tenant) (rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "{{tenant}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 8,
"title": "Failing, by tenant",
"type": "timeseries",
"description": "One tenant alone failing is usually what it sends; all of them together is a model.",
"gridPos": {
"x": 12,
"y": 13,
"w": 12,
"h": 8
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sum by (tenant) (rate(llmgw_tenant_requests_total{tenant=~\"$tenant\",outcome=\"error\"}[5m])) / sum by (tenant) (rate(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[5m]))",
"legendFormat": "{{tenant}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 9,
"title": "First token p95, by tenant",
"type": "timeseries",
"description": "",
"gridPos": {
"x": 0,
"y": 21,
"w": 12,
"h": 8
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "histogram_quantile(0.95, sum by (tenant, le) (rate(llmgw_tenant_ttft_seconds_bucket{tenant=~\"$tenant\"}[10m])))",
"legendFormat": "{{tenant}}",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "s"
},
"overrides": []
},
"options": {
"legend": {
"displayMode": "table",
"placement": "right",
"calcs": [
"mean",
"max"
]
},
"tooltip": {
"mode": "multi",
"sort": "desc"
}
}
},
{
"id": 10,
"title": "Budgets spent this period",
"type": "bargauge",
"description": "Every organization, team, project and person with a budget. Requests are refused at 100%.",
"gridPos": {
"x": 12,
"y": 21,
"w": 12,
"h": 8
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sort_desc(llmgw_budget_spent_ratio)",
"legendFormat": "{{scope}} {{name}} ({{period}})",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
}
}
],
"fieldConfig": {
"defaults": {
"unit": "percentunit",
"min": 0,
"max": 1,
"thresholds": {
"mode": "absolute",
"steps": [
{
"color": "green",
"value": null
},
{
"color": "orange",
"value": 0.8
},
{
"color": "red",
"value": 1
}
]
}
},
"overrides": []
},
"options": {
"displayMode": "gradient",
"orientation": "horizontal",
"reduceOptions": {
"calcs": [
"lastNotNull"
]
},
"showUnfilled": true
}
},
{
"id": 11,
"title": "Who used most, last 24 hours",
"type": "table",
"description": "Spend in money is on the people page's usage tab, which reads it from the store.",
"gridPos": {
"x": 0,
"y": 29,
"w": 24,
"h": 8
},
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"targets": [
{
"refId": "A",
"expr": "sort_desc(sum by (tenant) (increase(llmgw_tenant_requests_total{tenant=~\"$tenant\"}[24h])))",
"legendFormat": "requests",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"format": "table",
"instant": true
},
{
"refId": "B",
"expr": "sum by (tenant) (increase(llmgw_tenant_tokens_total{tenant=~\"$tenant\"}[24h]))",
"legendFormat": "tokens",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"format": "table",
"instant": true
},
{
"refId": "C",
"expr": "sum by (tenant) (increase(llmgw_tenant_requests_total{tenant=~\"$tenant\",outcome=\"limited\"}[24h]))",
"legendFormat": "limited",
"datasource": {
"type": "prometheus",
"uid": "llmgw-prom"
},
"format": "table",
"instant": true
}
],
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"showHeader": true
},
"transformations": [
{
"id": "merge",
"options": {}
},
{
"id": "organize",
"options": {
"excludeByName": {
"Time": true
},
"renameByName": {
"Value #A": "Requests",
"Value #B": "Tokens",
"Value #C": "Refused by a limit"
}
}
}
]
}
]
}
+435 -4
View File
@@ -42,10 +42,131 @@ groups:
annotations:
summary: "{{ $labels.model }} has no replica ready"
description: >-
The model is placed and nothing is serving it. Ten minutes, because
a large model on CPU legitimately takes minutes to load. A model
that is deliberately stopped publishes no series at all, so this
cannot fire for one nobody asked for.
The model should be running and nothing is serving it. Ten minutes,
because a large model on CPU legitimately takes minutes to load. The
series is published at 0 for every model that should be running —
before v0.10.4 it was published only while a replica was ready, so
this could not fire at all. A model deliberately stopped, or an
on-demand one nobody is asking for, publishes nothing.
- alert: ModelParked
expr: llmgw_model_parked == 1
labels: { severity: critical }
annotations:
summary: "{{ $labels.model }} failed every time it started and is no longer retried"
description: >-
Five replicas in a row failed at start. Before this existed a model
whose weights its engine could not load was started 155 times over
eight hours. `llmgw ctl status` has the last error; once it is dealt
with, `llmgw ctl retry <model>`. Changing the model's definition
also retries it.
- alert: ModelFailingToStart
expr: llmgw_model_start_failures >= 2
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.model }} has failed to start {{ $value }} times in a row"
description: >-
On its way to being parked. The engine's own words are in
`llmgw ctl logs <node> --source <shard>` for the next attempt, or in
the control plane's log for the last one.
- alert: ModelCannotBePlaced
expr: llmgw_model_unplaceable == 1
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $labels.model }} is wanted and fits on no node"
description: >-
Short of replicas, and the last attempt found nowhere with the
memory, cards or engine it needs. `llmgw ctl explain-placement
<model>` says what each node lacks. For an on-demand model, requests
for it are told this rather than asked to retry.
- alert: ModelPreempted
expr: increase(llmgw_model_preempted_total[15m]) > 0
labels: { severity: info }
annotations:
summary: "{{ $labels.model }} ({{ $labels.class }}) gave up replicas to a higher class"
description: >-
Capacity ran short and a model of a higher class took this one's
room. Expected now and then; if it keeps firing for the same model,
the cluster is too small for what is asked of it at these classes.
`llmgw ctl models` shows who it gave way to.
- alert: CapacityAtLimit
expr: llmgw_capacity_holds >= on(scope, resource) llmgw_capacity_limit
for: 15m
labels: { severity: info }
annotations:
summary: "{{ $labels.scope }} holds all the {{ $labels.resource }} it may"
description: >-
Its models get no further replicas, under load or not, until
something of theirs stops or the limit is raised: `llmgw ctl quota
list`, `llmgw ctl quota set`.
- alert: EngineInstallFailed
expr: llmgw_node_engine_install{state="failed"} == 1
for: 15m
labels: { severity: warning }
annotations:
summary: "{{ $labels.engine }} {{ $labels.version }} will not install on {{ $labels.node }}"
description: >-
The node takes no model needing it until it does. The agent retries
on its own; `llmgw ctl nodes` and the node's logs on the cluster
page say why it failed.
- alert: ControlPlaneLeadership
expr: sum(llmgw_control_plane_leader) != 1
for: 2m
labels: { severity: critical }
annotations:
summary: "{{ $value }} control planes believe they are placing work"
description: >-
None means nothing is placed or replaced; two means both are, each
into GPUs the other believes free. The lease is in the store: check
it is reachable from every control plane.
- alert: CertificateExpiresSoon
expr: (llmgw_certificate_expiry_timestamp_seconds - time()) < 30 * 86400
labels: { severity: warning }
annotations:
summary: "{{ $labels.cert }} ({{ $labels.subject }}) expires in {{ $value | humanizeDuration }}"
description: >-
Certificates last a year and nothing renews them. When the agent
server's runs out every agent refuses to connect at once. Reissue
with `llmgw ctl cert` and restart the control plane.
- alert: CertificateExpiresVerySoon
expr: (llmgw_certificate_expiry_timestamp_seconds - time()) < 7 * 86400
labels: { severity: critical }
annotations:
summary: "{{ $labels.cert }} ({{ $labels.subject }}) expires in {{ $value | humanizeDuration }}"
description: See CertificateExpiresSoon; a week is not long.
- alert: BackupTooOld
expr: time() - llmgw_backup_last_success_timestamp_seconds > 26 * 3600
labels: { severity: warning }
annotations:
summary: "The newest llmgw backup is {{ $value | humanizeDuration }} old"
description: >-
llmgw-backup.timer runs nightly. Also fires when there has never
been a backup (the series is 0 then). `journalctl -u
llmgw-backup.service` on the control plane says why the last one
failed; docs/install.md, "Backing up", says how to set it up.
- alert: ModelUpdateAvailable
expr: llmgw_model_update_available == 1
for: 1h
labels: { severity: info }
annotations:
summary: "{{ $labels.repo }} has new weights for {{ $labels.model }}"
description: >-
The registry repository the model was served from has published
different files under the same names. `llmgw ctl model updates`
lists what changed; `llmgw ctl model update {{ $labels.model }}`
moves it to them, and `llmgw ctl model rollback` goes back.
- alert: PoolSaturated
expr: llmgw_pool_saturation > 1
@@ -91,6 +212,94 @@ groups:
is skipped by placement rather than failing, so this is quiet until
somebody wonders why a model will not land there.
- name: llmgw-gpus
rules:
- alert: GPUThermalThrottling
expr: llmgw_gpu_throttled{reason=~"sw_thermal|hw_thermal|hw_slowdown"} == 1
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} is slowed by heat ({{ $labels.reason }})"
description: >-
Ten minutes held back by temperature: every replica on the card is
slower than its measurements say. Usually airflow, a fan, or the
room — the node page shows the card's temperature over time.
- alert: GPUUncorrectedECCErrors
expr: llmgw_gpu_ecc_errors > 0
for: 1m
labels: { severity: critical }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} has {{ $value }} uncorrected ECC errors"
description: >-
Uncorrected memory errors since the driver loaded: what runs on the
card may be computing on corrupted memory. Drain the node
(llmgw ctl drain) and have the card looked at.
- alert: GPUXidError
expr: increase(llmgw_gpu_xid_errors[15m]) > 0
for: 0m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} logged an XID error"
description: >-
The driver reported an error on the card (its code is
llmgw_gpu_last_xid, and the node page says it in words). 79 is a
card fallen off the bus, 48 and 63/64 memory errors, 74 NVLink,
94/95 contained and uncontained ECC errors: those need the card
looked at. Others are often an engine's own fault, and pass.
- alert: GPUOutOfSpareRows
expr: llmgw_gpu_remap_failure == 1
for: 1m
labels: { severity: critical }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} has no spare memory rows left"
description: >-
The card cannot remap another failing row. Drain the node and
have the card replaced.
- alert: GPUNeedsReset
expr: llmgw_gpu_remap_pending == 1 or llmgw_gpu_retirement_pending == 1
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} has memory repairs waiting for a reset"
description: >-
Rows remapped or pages retired take effect only after the card is
reset. Drain the node (llmgw ctl drain), reset or reboot, and bring
it back.
- alert: GPUPCIeRetriesClimbing
expr: increase(llmgw_gpu_pcie_replays[1h]) > 100
for: 0m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }}: {{ $value }} PCIe retries in an hour"
description: >-
The card's link to the host is retrying. A riser, the slot or a
cable is the usual cause, and transfers to the card slow down.
- alert: GPUNVLinkErrorsClimbing
expr: increase(llmgw_gpu_nvlink_errors{kind="crc"}[1h]) > 10
for: 0m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }}: NVLink CRC errors climbing"
description: >-
Errors on the card's NVLink links: tensor-parallel replicas on it
slow down, and a link that keeps failing takes a replica with it.
- alert: GPUHot
expr: llmgw_gpu_temperature_celsius > 87
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $labels.node }} GPU {{ $labels.gpu }} at {{ $value }} °C"
description: >-
Ten minutes above 87 °C, where most data-centre cards begin to
throttle.
- name: monitoring-host
rules:
- alert: MonitoringDiskFilling
@@ -153,3 +362,225 @@ groups:
Working as designed when the cluster is full: the lowest band is
held first. Worth knowing about because the callers being refused
do not know why.
# Per tenant: the level router.tenant_label names (a team by default), and
# every organization, team, project and person given a budget.
- name: llmgw-tenants
rules:
- alert: BudgetMostlySpent
expr: llmgw_budget_spent_ratio >= 0.8 and llmgw_budget_spent_ratio < 1
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.scope }} {{ $labels.name }} has spent {{ $value | humanizePercentage }} of its {{ $labels.period }}ly budget"
description: >-
Its requests are refused once it reaches all of it, until the
period turns. Raise it on the people page, or tell them now
rather than when they are refused.
- alert: BudgetSpent
expr: llmgw_budget_spent_ratio >= 1
for: 1m
labels: { severity: critical }
annotations:
summary: "{{ $labels.scope }} {{ $labels.name }} has spent its {{ $labels.period }}ly budget"
description: >-
Every request under it is refused with 429 until the period
turns, unless a cheaper model is named as its fallback.
- alert: TenantHittingLimits
expr: >-
sum by (tenant) (rate(llmgw_tenant_requests_total{outcome="limited"}[10m]))
/ sum by (tenant) (rate(llmgw_tenant_requests_total[10m])) > 0.1
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $value | humanizePercentage }} of {{ $labels.tenant }}'s requests are refused by a limit"
description: >-
A rate, token or budget limit is turning their traffic away. Either
the limit is too low for what they now do, or something of theirs
is looping.
- alert: TenantErrors
expr: >-
sum by (tenant) (rate(llmgw_tenant_requests_total{outcome="error"}[10m]))
/ sum by (tenant) (rate(llmgw_tenant_requests_total[10m])) > 0.05
and sum by (tenant) (rate(llmgw_tenant_requests_total[10m])) > 0.05
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $value | humanizePercentage }} of {{ $labels.tenant }}'s requests fail"
description: >-
Not counting refusals by a limit. If every tenant fails alike it
is a model (ModelFailing); one alone is usually what they send —
prompts past the context, a model they may not use.
- alert: TenantTrafficSpike
expr: >-
sum by (tenant) (rate(llmgw_tenant_requests_total[30m]))
> 3 * sum by (tenant) (rate(llmgw_tenant_requests_total[7d]))
and sum by (tenant) (rate(llmgw_tenant_requests_total[30m])) > 0.2
for: 15m
labels: { severity: info }
annotations:
summary: "{{ $labels.tenant }} is sending three times its weekly average"
description: >-
A new workload, a batch job, or a client retrying in a loop. Only
for a tenant sending more than one request every five seconds, so
a quiet team starting work in the morning does not page anybody.
- alert: TenantSlowFirstToken
expr: >-
histogram_quantile(0.95,
sum by (tenant, le) (rate(llmgw_tenant_ttft_seconds_bucket[15m]))) > 10
and sum by (tenant) (rate(llmgw_tenant_ttft_seconds_count[15m])) > 0.02
for: 15m
labels: { severity: warning }
annotations:
summary: "{{ $labels.tenant }} waits {{ $value | humanizeDuration }} for a first token at p95"
description: >-
What their people feel. Compare with SlowFirstToken per model: if
only this tenant is slow, it is the models it uses or its queue
band, not the cluster.
# The store, when it is a replicated cluster under Patroni (llmgw server
# init --patroni). Published only for a store of several hosts.
- name: llmgw-store
rules:
- alert: StoreHasNoLeader
expr: llmgw_store_patroni_up == 1 and on() count(llmgw_store_member{role="leader"}) == 0
for: 1m
labels: { severity: critical }
annotations:
summary: "The store's cluster has no primary"
description: >-
Every write fails — keys, usage, budgets, leadership. Patroni
promotes a standby within its 30-second TTL; a minute without one
means it cannot: etcd has lost its majority, or no standby is fit.
`patronictl -c /etc/patroni/config.yml list` on a member says which.
- alert: StorePatroniUnreachable
expr: llmgw_store_patroni_up == 0
for: 2m
labels: { severity: warning }
annotations:
summary: "No member of the store's cluster answers on its Patroni port"
description: >-
The control plane reads the cluster's state from port 8008 on each
member. The database may still be serving; nothing here can tell.
- alert: StoreReplicaNotStreaming
expr: llmgw_store_member{role!="leader", state!="streaming"} == 1
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $labels.member }} is {{ $labels.state }}, not streaming from the primary"
description: >-
A standby that is not streaming is not a standby: if the primary
is lost now, there is nothing to promote. "missing" is a host of
the store address the cluster no longer lists — usually a machine
that is down.
- alert: StoreReplicaBehind
expr: llmgw_store_replica_lag_bytes > 64 * 1024 * 1024 or llmgw_store_replica_lag_bytes == -1
for: 10m
labels: { severity: warning }
annotations:
summary: "{{ $labels.member }} is {{ $value | humanize1024 }}B behind the primary"
description: >-
Patroni will not promote a standby more than 1 MB behind, so a
failover now would find none fit. -1 is a lag Patroni cannot read.
- name: llmgw-guard
rules:
- alert: GuardFailingOpen
expr: >-
sum(rate(llmgw_guard_fail_open_total[5m]))
/ clamp_min(sum(rate(llmgw_guard_checks_total[5m])), 1e-9) > 0.01
for: 5m
labels: { severity: warning }
annotations:
summary: "{{ $value | humanizePercentage }} of judged traffic is served unjudged"
description: >-
A guard policy's judge model is not answering within its latency
budget, and the policy fails open: requests go through flagged
"judge unavailable" instead of being judged.
llmgw_guard_judge_errors_total says whether it is timing out or
not there; `llmgw ctl replicas` whether the judge has replicas.
- alert: GuardJudgeSlow
expr: >-
histogram_quantile(0.95, sum by (le, judge_model) (rate(llmgw_guard_judge_latency_seconds_bucket[5m]))) > 0.25
for: 10m
labels: { severity: warning }
annotations:
summary: "Judge {{ $labels.judge_model }} answers in {{ $value | humanizeDuration }} at p95"
description: >-
Over the default latency budget: every judged request waits this
long, and those over their policy's budget are served unjudged or
refused. Give the judge more replicas, or faster ones.
- alert: GuardJudgeUnavailable
expr: sum by (reason) (rate(llmgw_guard_judge_errors_total{reason!="timeout"}[5m])) > 0
for: 5m
labels: { severity: critical }
annotations:
summary: "The guardrails cannot ask their judge ({{ $labels.reason }})"
description: >-
no_judge: a policy names a model that is not in the catalog with
role: guard. unavailable: it has no replica answering. format: it
answers in a form its guard.format does not read.
- alert: GuardBlocksSpiking
expr: >-
sum by (tenant) (rate(llmgw_tenant_guard_blocks_total[1h]))
> 3 * sum by (tenant) (rate(llmgw_tenant_guard_blocks_total[7d]))
and sum by (tenant) (increase(llmgw_tenant_guard_blocks_total[1h])) > 10
for: 15m
labels: { severity: warning }
annotations:
summary: "{{ $labels.tenant }} is being blocked three times as often as usual"
description: >-
A tenant's requests or answers are blocked by the guardrails
more than three times their weekly rate. Someone probing, a
client sending data it should not, or a policy that changed.
The /admin/guard page lists the recent ones by request.
- alert: GuardSecretInAnswer
expr: sum(increase(llmgw_guard_checks_total{stage="output",category="secret",action!="allowed"}[5m])) > 0
labels: { severity: critical }
annotations:
summary: "A model put a credential in an answer"
description: >-
An answer contained something shaped like a key or a private key.
It was stopped or rewritten if the policy enforces; either way a
model has seen it, in its prompt or its training. The usage rows
with guard_category secret say which request.
- name: llmgw-slo
rules:
- alert: ModelTTFTOverSLO
expr: >-
histogram_quantile(0.99, sum by (le, model) (rate(llmgw_ttft_seconds_bucket[10m])))
> on (model) group_left llmgw_model_slo_seconds{kind="ttft_p99"}
for: 15m
labels: { severity: warning }
annotations:
summary: "{{ $labels.model }} answers its first token past its SLO at p99"
description: >-
The p99 time to the first token over ten minutes is past the
model's slo.ttft_p99. Admission already holds the lower bands back
while it is; this says it has lasted. More replicas, or less
traffic in the bands above.
- alert: ModelTPOTOverSLO
expr: >-
histogram_quantile(0.99, sum by (le, model) (rate(llmgw_tpot_seconds_bucket[10m])))
> on (model) group_left llmgw_model_slo_seconds{kind="tpot_p99"}
for: 15m
labels: { severity: warning }
annotations:
summary: "{{ $labels.model }} produces tokens slower than its SLO at p99"
description: >-
The p99 time per output token is past slo.tpot_p99: replicas are
batching more than they can decode quickly, or a slower card is
serving. llmgw_output_tokens_per_second by pool says which.
+24 -1
View File
@@ -21,11 +21,34 @@ 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,
# itself, the store's members — so a node added is a node scraped with
# 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
@@ -10,6 +10,9 @@ put it behind a proxy that holds a publicly trusted certificate.
Both are also attached to every release.
The `/about` page shows both, from copies in `internal/web/proxy`; a test fails
when those differ from these, so change them here and copy them over.
Four things both files get right, and any other proxy has to as well:
- **Streaming is not buffered.** Replies are server-sent events; a buffering
+7
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
@@ -11,6 +14,10 @@ ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
KillMode=process
Restart=on-failure
RestartSec=2s
# RDMA pins the buffers it registers, and NCCL registers a great deal: with
# systemd's default 8 MB lock limit, an engine that drops the capability that
# lets root ignore it fails its first collective over InfiniBand.
LimitMEMLOCK=infinity
StateDirectory=llmgw-agent
[Install]
+14
View File
@@ -0,0 +1,14 @@
[Unit]
Description=llmgw backup: the store and /etc/llmgw, to /var/backups/llmgw-server
After=llmgw-server.service postgresql.service
[Service]
Type=oneshot
# Nothing it writes is for anyone else: the archive holds the CA's key and a
# copy of the store.
UMask=0077
# Root, because the archive includes /etc/llmgw/secrets and the certificate
# authority's key, which the llmgw user deliberately cannot read all of.
ExecStart=/usr/local/bin/llmgw server backup --config /etc/llmgw/server.yaml --keep 14
Nice=10
IOSchedulingClass=idle
+12
View File
@@ -0,0 +1,12 @@
[Unit]
Description=Back up llmgw every night
[Timer]
OnCalendar=*-*-* 03:15:00
# A machine that was off at 03:15 backs up when it next starts, rather than
# waiting another day with a gap nobody notices until the alert.
Persistent=true
RandomizedDelaySec=10m
[Install]
WantedBy=timers.target
+109
View File
@@ -0,0 +1,109 @@
// Package llmgw holds what the binary carries besides code: its manual, the
// description of its admin API, and the example quality suites.
package llmgw
import (
"embed"
"io/fs"
"sort"
"strings"
)
// Docs is the documentation, read on any machine by `llmgw docs` and the
// console's manual without a checkout or the internet.
//
//go:embed README.md CHANGELOG.md docs/*.md
var Docs embed.FS
// Doc is one page of the manual.
type Doc struct {
// Key is what `llmgw docs KEY` takes, and the page's address under /docs/
// on the gateway.
Key string
// Path is the file in Docs.
Path string
// Title is the document's first heading.
Title string
}
// Source is the document's markdown.
func (d Doc) Source() string {
b, err := fs.ReadFile(Docs, d.Path)
if err != nil {
return "# " + d.Path + "\n\nNot in this build."
}
return string(b)
}
// docOrder is the documents in the order someone new reads them. Here rather
// 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", "knowledge", "choosing-models", "fabrics", "facility-aware-placement", "data",
"reference", "development"}
// Manual is every document, in reading order: those docOrder names, then any
// other under docs/ alphabetically, so a new one is never missing, then the
// README and the changes.
func Manual() []Doc {
var out []Doc
seen := map[string]bool{}
add := func(key, path string) {
if seen[key] {
return
}
b, err := fs.ReadFile(Docs, path)
if err != nil {
return
}
seen[key] = true
out = append(out, Doc{Key: key, Path: path, Title: titleOf(string(b), path)})
}
for _, k := range docOrder {
add(k, "docs/"+k+".md")
}
entries, _ := fs.ReadDir(Docs, "docs")
var rest []string
for _, e := range entries {
if strings.HasSuffix(e.Name(), ".md") {
rest = append(rest, strings.TrimSuffix(e.Name(), ".md"))
}
}
sort.Strings(rest)
for _, k := range rest {
add(k, "docs/"+k+".md")
}
add("readme", "README.md")
add("changes", "CHANGELOG.md")
return out
}
// titleOf is a document's first top-level heading, or its path when it has
// none.
func titleOf(src, path string) string {
for _, l := range strings.Split(src, "\n") {
if strings.HasPrefix(l, "# ") {
return strings.TrimSpace(strings.TrimPrefix(l, "# "))
}
}
return path
}
// AdminOpenAPI is the admin API described in OpenAPI 3.1, generated from its
// route table by `go generate` in internal/docgen and served at
// /api/v1/openapi.json, so a client can be pointed at the server it talks to.
//
//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.
//
//go:embed deploy/eval/*.yaml
var EvalSuites embed.FS
+169
View File
@@ -0,0 +1,169 @@
# The admin API
Written for whoever automates a cluster rather than typing at it: how to reach
the control plane's management API, what it asks of a request, and where its
description is.
`llmgw ctl` and the console are clients of this API and nothing more, so
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, 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
The API is under `/api/v1` on the admin listener, `admin_listen` in
`/etc/llmgw/server.yaml`: `127.0.0.1:9090` unless changed. It is on loopback
on purpose. The same listener serves pprof when it is turned on, and the
server refuses to start with it on any other address unless
`admin_remote: true` — the token would cross the network in clear.
From another machine, tunnel to it rather than moving it:
```sh
ssh -N -L 9090:127.0.0.1:9090 admin@control-plane &
curl -s -H "Authorization: Bearer $LLMGW_ADMIN_TOKEN" http://127.0.0.1:9090/api/v1/summary
```
With `admin_remote: true` and `admin_listen: 10.0.0.5:9090`, call that address
directly — on a network you trust, or behind a TLS proxy that keeps the
`Host` header an address.
## The token
Every path under `/api` needs the admin token as a bearer token:
```
Authorization: Bearer 6f1c…
```
The server writes it the first time it starts, 0600, to
`/var/lib/llmgw/admin-token` under its systemd unit (`admin_token_file`
moves it; a server run by hand keeps it in `secrets/admin-token` beside its
configuration). `llmgw ctl` reads it from there, which is why it runs with
sudo, or from `LLMGW_ADMIN_TOKEN`. To give it to automation:
```sh
sudo cat /var/lib/llmgw/admin-token
```
The token is the whole of the API's authorisation: whoever holds it can do
everything the API does. Keep it where you keep other root credentials.
## What a request must look like
Besides the token, the listener refuses three things before a handler sees
them, as defences against a browser being turned against it:
- **A name in `Host`.** The listener answers to an address or `localhost`,
not a DNS name, so a page on some other site cannot reach it by rebinding
a name to 127.0.0.1. Call it as `http://127.0.0.1:9090`, not
`http://cp.example.com:9090`; behind a proxy, have the proxy send an
address.
- **A cross-site request.** One whose `Origin` is another host, or whose
`Sec-Fetch-Site` says another site sent it.
- **A change that is not JSON.** Every method but `GET` and `HEAD` must carry
`Content-Type: application/json`, even a `DELETE` or a `POST` with no body.
A form post from a page cannot set that header.
A change is recorded in the audit log with who made it, as the request says
in `X-Llmgw-Actor` (letters, digits and `._-@`, at most 64; `unknown` when
absent). `llmgw ctl` sends the name of whoever ran it; automation should send
its own, `terraform` or `ci@deploy`, so `llmgw ctl audit` can tell them apart.
A refusal is a 4xx or 5xx with `{"error": "…"}` saying why. 501 means this
control plane lacks what the endpoint needs — a store, a mirror, guard
policies — and says which.
## Some calls
```sh
H=(-H "Authorization: Bearer $LLMGW_ADMIN_TOKEN" -H "Content-Type: application/json" -H "X-Llmgw-Actor: $USER")
API=http://127.0.0.1:9090/api/v1
# The fleet in one answer, and every model.
curl -s "${H[@]}" $API/summary
curl -s "${H[@]}" $API/models
# Add a model, read back its definition with the defaults filled in, scale it.
curl -s "${H[@]}" -X POST $API/models -d '{
"name": "qwen-small",
"source": "/srv/models/qwen2.5-1.5b-instruct-q4_k_m.gguf",
"profiles": [{"name": "cpu", "engine": "llamacpp", "min_memory_gb": 4, "cpu_cores": 4}],
"scaling": {"min": 1, "max": 2}
}'
curl -s "${H[@]}" $API/models/qwen-small
curl -s "${H[@]}" -X POST $API/models/qwen-small/scale -d '{"min": 0, "max": 2, "idle_timeout": 1800000000000}'
# 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
```
Durations in bodies are integer nanoseconds, as Go encodes them: half an hour
is `1800000000000`.
## The description
The API is described in OpenAPI 3.1:
- served by every control plane at `GET /api/v1/openapi.json` (with the
token, like the rest), as built into that binary, so it describes the
server you are talking to;
- committed as [admin-api.openapi.json](admin-api.openapi.json) for reading
without a cluster.
```sh
curl -s "${H[@]}" $API/openapi.json > llmgw-admin.json
```
It lists every endpoint, its path and query parameters, and the schema of
each request and answer, with the meaning of each field. Load it into any
OpenAPI tool — Swagger UI, Redoc, an editor — or generate a client from it:
```sh
docker run --rm -v "$PWD:/w" openapitools/openapi-generator-cli generate \
-i /w/llmgw-admin.json -g python -o /w/llmgw-admin-client
```
### How it is kept true
The document is generated, not written. `internal/control/adminapi/routes.go`
holds a table of every endpoint: its method and path, a summary, its query
parameters, and Go values of the types it reads and answers with. The
generator in `internal/docgen/openapi` reads those types by reflection, the
way `encoding/json` encodes them, and takes each field's description from its
comment in the source.
Two tests keep it from drifting. One registers the API on a recording mux and
fails when an endpoint is served and not in the table, or in the table and not
served. The other regenerates the document and fails when it differs from the
committed one. After changing an endpoint or a type it carries:
```sh
go generate ./internal/docgen
```
## What it does not cover
- **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
pages and `/debug/pprof`, which are not under `/api`.
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -141,7 +141,7 @@ not count the same free terabyte once per node.
Set budgets and limits on day one, not later:
```
llmgw keys limits --scope project --id <project> --rpm 60 --budget 50000000
llmgw keys limits --scope project --id <project> --rpm 60 --budget 50
```
The reason is not technical. A resource that appears free and unlimited becomes,
File diff suppressed because it is too large. Load diff
+633
View File
@@ -0,0 +1,633 @@
# Using llmgw from code
Written for a developer whose program talks to a model through llmgw. There
is no llmgw SDK, and there will not be one: the gateway speaks OpenAI's API
and Anthropic's Messages API, so the official SDKs for both work as they are.
Only two things change — the base URL and the key. Everything llmgw adds on
top is an HTTP header, which every one of those SDKs lets you set.
The examples use `https://llm.example.com` for the gateway; use the address
your operator gave you, or the one on the gateway's own front page. Every
example reads the key from `LLMGW_API_KEY`. `<model-name>` is a model this
gateway serves: `GET /v1/models` lists the ones your key may use, and sending
the placeholder unchanged is answered with that list rather than with an
error.
## Getting a key
Keys are made on the gateway's **Account** page after signing in, or issued
by an operator. A key is shown once and never again — only its hash is kept —
so put it in a password manager straight away. `GET /me` with the key says
what it may do and what it has spent.
## The base URL and the key
| SDK | Base URL | Key |
|---|---|---|
| OpenAI (any language) | `https://llm.example.com/v1` | `Authorization: Bearer …`, as the SDK sends it |
| Anthropic (any language) | `https://llm.example.com` — the SDK adds `/v1/messages` | `x-api-key: …`, as the SDK sends it |
Both SDKs also read their usual environment variables, so a program that
already uses them needs no change at all:
```
export OPENAI_BASE_URL=https://llm.example.com/v1
export OPENAI_API_KEY=$LLMGW_API_KEY
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
```
curl https://llm.example.com/v1/chat/completions \
-H "Authorization: Bearer $LLMGW_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "<model-name>",
"messages": [{"role": "user", "content": "Say hello in Turkish."}]}'
```
### Python
```
pip install openai
```
```python
import os
from openai import OpenAI
client = OpenAI(base_url="https://llm.example.com/v1",
api_key=os.environ["LLMGW_API_KEY"])
reply = client.chat.completions.create(
model="<model-name>",
messages=[{"role": "user", "content": "Say hello in Turkish."}],
)
print(reply.choices[0].message.content)
```
### TypeScript
```
npm install openai
```
```typescript
import OpenAI from "openai";
const client = new OpenAI({
baseURL: "https://llm.example.com/v1",
apiKey: process.env.LLMGW_API_KEY,
});
const reply = await client.chat.completions.create({
model: "<model-name>",
messages: [{ role: "user", content: "Say hello in Turkish." }],
});
console.log(reply.choices[0].message.content);
```
### Go
```
go get github.com/openai/openai-go/v3
```
```go
package main
import (
"context"
"fmt"
"log"
"os"
"github.com/openai/openai-go/v3"
"github.com/openai/openai-go/v3/option"
)
func main() {
client := openai.NewClient(
option.WithBaseURL("https://llm.example.com/v1/"),
option.WithAPIKey(os.Getenv("LLMGW_API_KEY")),
)
reply, err := client.Chat.Completions.New(context.Background(), openai.ChatCompletionNewParams{
Model: "<model-name>",
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("Say hello in Turkish.")},
})
if err != nil {
log.Fatal(err)
}
fmt.Println(reply.Choices[0].Message.Content)
}
```
The import path carries the SDK's major version; use the one your module
already has.
## Streaming
Set `stream` and read the chunks. llmgw may send a last chunk that carries
only the token counts and no choices, so check for a choice before reading
one — the OpenAI API does the same when usage is asked for.
```python
stream = client.chat.completions.create(
model="<model-name>",
messages=[{"role": "user", "content": "Count to ten."}],
stream=True,
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
print()
```
```typescript
const stream = await client.chat.completions.create({
model: "<model-name>",
messages: [{ role: "user", content: "Count to ten." }],
stream: true,
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
```
```go
stream := client.Chat.Completions.NewStreaming(ctx, openai.ChatCompletionNewParams{
Model: "<model-name>",
Messages: []openai.ChatCompletionMessageParamUnion{openai.UserMessage("Count to ten.")},
})
for stream.Next() {
chunk := stream.Current()
if len(chunk.Choices) > 0 {
fmt.Print(chunk.Choices[0].Delta.Content)
}
}
if err := stream.Err(); err != nil {
log.Fatal(err)
}
```
With curl, add `"stream": true` to the body and `-N` so curl prints the
events as they come.
## Tools
Function calling is OpenAI's: describe the tools, run the ones the model
asks for, send the results back. Whether a model calls tools well is the
model's business, not the gateway's; ask your operator which of the served
models are set up for it.
**Editors' agents need such a model.** Continue's Agent mode, Cline and
Codex act only through tool calls: with a model whose calls come back as
text — `<tool_call>{...}` or a JSON block in the answer, `tool_calls` empty —
they chat but never read a file or run a command. Qwen2.5-Coder is one;
Qwen3-Coder is not. The configuration the portal writes for Continue says
`tool_use` only for a model whose catalog entry says tools, and gives
Continue the model's context and longest answer, so it never sends a request
the gateway refuses.
```python
import json
tools = [{
"type": "function",
"function": {
"name": "get_weather",
"description": "The current weather in a city.",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"],
},
},
}]
messages = [{"role": "user", "content": "Is it raining in Ankara?"}]
reply = client.chat.completions.create(model="<model-name>", messages=messages, tools=tools)
msg = reply.choices[0].message
if msg.tool_calls:
messages.append(msg)
for call in msg.tool_calls:
args = json.loads(call.function.arguments)
result = {"city": args["city"], "raining": False} # your code here
messages.append({"role": "tool", "tool_call_id": call.id,
"content": json.dumps(result)})
reply = client.chat.completions.create(model="<model-name>", messages=messages, tools=tools)
print(reply.choices[0].message.content)
```
## Embeddings
```python
result = client.embeddings.create(
model="<embedding-model>",
input=["llmgw routes requests", "to GPUs it can reach"],
)
vectors = [d.embedding for d in result.data]
```
```typescript
const result = await client.embeddings.create({
model: "<embedding-model>",
input: ["llmgw routes requests", "to GPUs it can reach"],
});
const vectors = result.data.map((d) => d.embedding);
```
An embedding model is a model of its own; a chat model does not answer
`/v1/embeddings`.
## The Responses API
`/v1/responses` is served too, for clients that speak nothing else (Codex
among them). llmgw translates it to chat completions, so it works with every
model the gateway serves; what has no equivalent there — OpenAI's hosted
tools, reasoning summaries — is ignored rather than refused.
```python
response = client.responses.create(
model="<model-name>",
instructions="Answer in one sentence.",
input="Why is the sky blue?",
)
print(response.output_text)
```
Streamed, the events are the Responses API's own:
```python
stream = client.responses.create(model="<model-name>", input="Count to ten.", stream=True)
for event in stream:
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
```
## Batches
For work that need not finish now: OpenAI's Files and Batch APIs. A batch runs
at the lowest priority, so it uses what interactive traffic leaves free, and
each line is counted against the key that submitted it.
```python
import json
with open("requests.jsonl", "w") as f:
for i, text in enumerate(["first document", "second document"]):
f.write(json.dumps({
"custom_id": f"doc-{i}",
"method": "POST",
"url": "/v1/chat/completions",
"body": {"model": "<model-name>",
"messages": [{"role": "user", "content": "Summarise: " + text}]},
}) + "\n")
batch_file = client.files.create(file=open("requests.jsonl", "rb"), purpose="batch")
batch = client.batches.create(
input_file_id=batch_file.id,
endpoint="/v1/chat/completions",
completion_window="24h",
)
# Later:
batch = client.batches.retrieve(batch.id)
if batch.status == "completed":
for line in client.files.content(batch.output_file_id).text.splitlines():
result = json.loads(line)
print(result["custom_id"], result["response"]["body"]["choices"][0]["message"]["content"])
```
## The Anthropic SDK
`/v1/messages` is Anthropic's Messages API, translated to chat completions and
back, streamed or not, with tools and images. It serves the gateway's own
models — `model` is a model this gateway serves, not a Claude model.
```
pip install anthropic
```
```python
import os
import anthropic
client = anthropic.Anthropic(base_url="https://llm.example.com",
api_key=os.environ["LLMGW_API_KEY"])
message = client.messages.create(
model="<model-name>",
max_tokens=1024,
messages=[{"role": "user", "content": "Say hello in Turkish."}],
)
print(message.content[0].text)
with client.messages.stream(
model="<model-name>",
max_tokens=1024,
messages=[{"role": "user", "content": "Count to ten."}],
) as stream:
for text in stream.text_stream:
print(text, end="", flush=True)
```
```typescript
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({
baseURL: "https://llm.example.com",
apiKey: process.env.LLMGW_API_KEY,
});
const message = await client.messages.create({
model: "<model-name>",
max_tokens: 1024,
messages: [{ role: "user", content: "Say hello in Turkish." }],
});
for (const block of message.content) {
if (block.type === "text") console.log(block.text);
}
```
```go
client := anthropic.NewClient(
option.WithBaseURL("https://llm.example.com/"),
option.WithAPIKey(os.Getenv("LLMGW_API_KEY")),
)
message, err := client.Messages.New(ctx, anthropic.MessageNewParams{
Model: "<model-name>",
MaxTokens: 1024,
Messages: []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock("Say hello in Turkish.")),
},
})
if err != nil {
log.Fatal(err)
}
for _, block := range message.Content {
if text, ok := block.AsAny().(anthropic.TextBlock); ok {
fmt.Println(text.Text)
}
}
```
(`github.com/anthropics/anthropic-sdk-go` and its `option` package.)
With curl:
```
curl https://llm.example.com/v1/messages \
-H "x-api-key: $LLMGW_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "Content-Type: application/json" \
-d '{"model": "<model-name>", "max_tokens": 1024,
"messages": [{"role": "user", "content": "Say hello in Turkish."}]}'
```
## What llmgw adds
Each is a header, so a request stays exactly what the SDK would send anywhere
else. Set one for every request on the client, or for one request:
| | Every request | One request |
|---|---|---|
| Python (both SDKs) | `OpenAI(..., default_headers={...})` | `create(..., extra_headers={...})` |
| TypeScript (both SDKs) | `new OpenAI({ ..., defaultHeaders: {...} })` | `create({...}, { headers: {...} })` |
| Go (both SDKs) | `openai.NewClient(..., option.WithHeader(k, v))` | `New(ctx, params, option.WithHeader(k, v))` |
| curl | | `-H "k: v"` |
### Stored conversations: `x-llmgw-thread`
Send the same thread id with each request and only the new turn; llmgw puts
the stored conversation in front of it. The conversation continues on
whichever model serves the next turn, even a different one. It must be
turned on for your project by an operator (`llmgw keys threads`); a request
that names a thread on a gateway that keeps none is refused rather than
answered without its history.
```python
import uuid
thread = str(uuid.uuid4()) # anyone in your project who knows it reads it
for turn in ["My name is Deniz.", "What is my name?"]:
reply = client.chat.completions.create(
model="<model-name>",
messages=[{"role": "user", "content": turn}],
extra_headers={"x-llmgw-thread": thread},
)
print(reply.choices[0].message.content)
```
A thread belongs to the project, not to the key, so use an id nobody can
guess: at most 128 letters, digits and `- _ . :`. When the conversation no
longer fits the model, the oldest turns are left out and the reply carries
`x-llmgw-thread-trimmed` with how many. `DELETE /v1/threads/{id}` forgets one:
```
curl -X DELETE https://llm.example.com/v1/threads/$THREAD \
-H "Authorization: Bearer $LLMGW_API_KEY"
```
### Sessions: `x-session-id`
Requests with the same session id go to the same replica while it is
healthy, so a conversation that resends its history finds it already in that
replica's cache and answers sooner. Without the header, the body's `user`
field is used. Nothing is stored; it is only a routing hint.
```python
client = OpenAI(base_url="https://llm.example.com/v1", api_key=os.environ["LLMGW_API_KEY"],
default_headers={"x-session-id": "chat-7f3a"})
```
### Tags and metadata
Requests can be labelled so the usage they record can be told apart — by
feature, by customer, by job. Send OpenAI's own `metadata`, string keys to
string values, or the same as the `x-llmgw-tags` header for a client that
cannot set `metadata`; the `user` field is recorded too. Which tags also
become metric labels is the operator's choice (`router.tag_labels`).
```python
reply = client.chat.completions.create(
model="<model-name>",
messages=[{"role": "user", "content": "Classify this ticket."}],
metadata={"feature": "triage", "customer": "acme"},
user="user-1234",
)
```
```
curl https://llm.example.com/v1/chat/completions \
-H "Authorization: Bearer $LLMGW_API_KEY" \
-H "x-llmgw-tags: feature=triage,customer=acme" \
-H "Content-Type: application/json" \
-d '{"model": "<model-name>", "messages": [{"role": "user", "content": "Classify this ticket."}]}'
```
### Canaries: `x-llmgw-variant`
While an operator tries a model's newer weights on a share of its traffic, a
session stays on one side. `x-llmgw-variant: canary` sends a request to the
canary deliberately — to try it before it is promoted, or to compare the two.
```python
reply = client.chat.completions.create(
model="<model-name>",
messages=[{"role": "user", "content": "hello"}],
extra_headers={"x-llmgw-variant": "canary"},
)
```
### Memory notes: `/v1/memory`
Notes an agent keeps for itself across conversations: a key, some text, and
optional tags, private to your project and to the agent named in
`x-llmgw-agent` (`default` when it is not sent). An operator turns them on for
a project (`llmgw keys memory`). They are plain REST, so any HTTP client
will do:
```
H="Authorization: Bearer $LLMGW_API_KEY"
curl -X PUT https://llm.example.com/v1/memory/favourite-colour -H "$H" \
-H "x-llmgw-agent: helper" -H "Content-Type: application/json" \
-d '{"text": "The user likes dark green.", "tags": ["preference"]}'
curl "https://llm.example.com/v1/memory?q=green" -H "$H" -H "x-llmgw-agent: helper"
curl "https://llm.example.com/v1/memory?tag=preference" -H "$H" -H "x-llmgw-agent: helper"
curl https://llm.example.com/v1/memory/favourite-colour -H "$H" -H "x-llmgw-agent: helper"
curl -X DELETE https://llm.example.com/v1/memory/favourite-colour -H "$H" -H "x-llmgw-agent: helper"
```
A key is up to 200 bytes, a note up to 16 KiB, with at most 16 tags of
lowercase letters, digits, `-` and `_`, and 1000 notes per agent.
### MCP: `/mcp`
The same notes as tools an agent can be given — `remember`, `recall` and
`forget` — from an MCP server at `https://llm.example.com/mcp` (the HTTP
transport, answered as plain JSON). Add it to your agent's MCP servers with
the key as a bearer token and, if you want notes kept apart, `x-llmgw-agent`.
llmgw does not put these tools into requests by itself: an agent that is not
given the server never sees its notes.
```
curl https://llm.example.com/mcp \
-H "Authorization: Bearer $LLMGW_API_KEY" \
-H "Content-Type: application/json" \
-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 |
|---|---|
| `x-request-id` | The request's id in the gateway's logs and usage records — quote it when asking an operator about a request. |
| `x-llmgw-served-model` | The model that answered, when it was not the one asked for: a spent budget can move a request to a cheaper model. |
| `x-llmgw-queue-ms` | How long the request waited for room before it was sent. |
| `x-llmgw-thread-trimmed` | How many stored turns were left out to fit the model. |
## When something is refused
Errors come back in the shape of the API that was called — OpenAI's
`{"error": {"message": …, "type": …}}` or Anthropic's — so each SDK raises
its usual exception with the gateway's message in it. The ones worth knowing:
| Status | Usually means |
|---|---|
| 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; 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 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
With keys on, the key decides the request's priority band and routing
profile, set by an operator; the `x-llmgw-band` and `x-llmgw-profile` headers
are ignored, so a client cannot promote itself. On a gateway without keys
they are read from the request.
+391
View File
@@ -0,0 +1,391 @@
# The console
`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 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.
The keyboard drives it; there is no mouse. **?** opens this page from
anywhere, and the **Manual** entry holds every other page, as `llmgw docs`
does without the console.
## The layout
A bar across the top names the machine. Down the left, the entries, in four
groups: **Watch**, **People**, **Help**, **Settings** and **Do**. The panel on
the right shows the entry chosen. The bottom line always says which keys do
what at that moment, and a question or a result opens as a dialog over the
panel.
| Key | Does |
|---|---|
| ↑ ↓ | Choose an entry, or a row in the panel |
| → or Enter | Go into the panel |
| Enter on a row | What can be done to it: its details first, then the rest |
| ← or Esc | Back out: from a page to its list, from the panel to the entries |
| A | Add one, on a view that holds things: a quota, an account, a key |
| PgUp PgDn | Scroll a long panel |
| ? | This page |
| Q | Leave; the cluster keeps running |
| Ctrl-C | Leave at once |
What is watched is read again every three seconds.
## Forms and questions
A change that needs values opens a form, filled in with what is set now. Tab
or ↓ moves to the next field, ← → changes a choice, and Enter on **Apply**
sends it; a form of one field is sent by Enter on it. A mistake the form can
see — a maximum below the minimum, a word where a number goes — is said in the
form and nothing is sent. One the control plane refuses comes back as a dialog
with its reason, word for word.
Anything that cannot be undone — removing, draining, deleting, revoking —
asks first, and only **Y** goes ahead.
Serving a model and moving one to new weights are jobs on the control plane:
started from here, they run there in the background whatever this screen
does, and the console is free at once. **Jobs** follows them. Upgrading the
cluster, a backup and adding a node change this machine or another over ssh;
those close the screen and run in the terminal, so their output is there to
read, and Enter brings the console back.
## Setting up
On a machine with nothing installed, the control plane is set up from one
form (`sudo llmgw server init`): the administrator's email, other names nodes
reach the machine by, and ticks for PostgreSQL, the model mirror, this machine
as a node, the starter model and the guardrails' judge. **Offline bundle**
and **Its sha256** are for a site with no internet: the path of a bundle made
by `llmgw bundle create`, and the sha256 it printed. Given, the bundle goes
into the mirror, every file checked, and the cluster is set up to fetch
nothing from outside (install.md, "Offline sites"). A bundle with no sha256,
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.
**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,
requests in flight and queued, network and disk — Braille charts, filled
and coloured green to red by height — over 5 minutes, an hour, a day or a
week (**1**–**4**, or **R** for the next). Below them each node's CPU,
memory and network now and as a sparkline, each card's busy, memory, heat
and power, and each model's requests a second. The history comes from the
Prometheus named by `monitoring.prometheus` in server.yaml; without one the
charts draw what this console has seen since it opened, and say so. Network
and disk come from each machine's node exporter, which `llmgw node add`
installs. **N** sends a test notification to each webhook in server.yaml's
`notify` section and says how each answered (`llmgw ctl notify test`). A node's and a model's own pages have their charts too, over the
same range.
**Nodes** lists every node with its state, where it is (its rack, or row,
room or site, and PDUs), load, GPUs, replicas, disk and agent version. A node
in a held rack reads *ready, held*. On a row:
- **Details** — its address, location and any hold, system, load, engines and
GPUs, and any warning.
- **Cordon** places nothing new on it; **Uncordon** undoes it.
- **Location…** sets its site, room, row, rack and PDUs (the `llmgw.io/`
labels), which placement spreads replicas across.
- **Hold rack …** places nothing new in the node's rack, and can drain it;
**Release …** on a node in a held domain undoes it. See
[facility-aware-placement.md](facility-aware-placement.md).
- **Drain** moves its replicas off one at a time, each replacement up before
the original stops.
- **vllm build / sglang build** (nodes with GPUs) — which build of the engine
the node installs: `auto` follows its hardware, `none` installs none.
- **Upgrade its agent** to a release; its engines keep serving.
- **Delete model files nothing uses** frees the node's disk.
- **Remove from the cluster** — the control plane forgets it; the machine is
not touched.
**GPUs** is every card: how busy, its memory, temperature, power, and the
model holding it.
**Models** lists the catalog: ready against wanted, status, class, profiles,
and **UPDATE** when the registry has new weights for it. On a row:
- **Details** — description, replicas, class, owner, profiles, source, where
its weights came from, what it reserves against what it uses, and its
replicas.
- **Replicas (scale)** — the minimum kept running, the most it scales to,
and for a model loaded on demand how long it may sit unused. A maximum of
0 stops it.
- **Class** — critical, standard or opportunistic: whose capacity gives way
when there is not enough for everyone.
- **Owner** — the organization, team or project whose capacity quota it
counts against.
- **Resources** — the memory and cores one replica reserves on its node.
- **LoRA adapters** — `name=source[@scale]`, separated by commas.
- **Restart its replicas**, one by one or in place.
- **Update to …** — when the repository it came from has published different
weights for its files: they are fetched into the mirror beside the ones in
use, checked, swapped in, and the replicas replaced one at a time.
**Go back to …** returns to the weights the update replaced.
**Ask the registry for new weights now** checks without waiting for the
next scheduled look (`mirror.update_check`, six hours by default).
- **Try the new weights as a canary…** — instead of swapping them in, runs
them beside the model as `MODEL-canary`, one replica, given the share of
its requests you choose. With a canary running the menu has **Canary's
share…**, **Promote the canary…** (the model moves to its weights, the
canary is removed) and **Take the canary out…**. A canary that errs or
answers more slowly than the model is taken out by itself. The MODEL
column shows `(10% to canary)` and `(canary of …)`.
- **Ask it a quality suite…** — one of the suites the control plane has (in
`control.eval_dir`, or built in: `arithmetic`, `json-format`), asked
through the router; the dialog counts the answers as they come in, then
shows the score, each case and the model's last runs. Esc stops watching;
the run carries on and is kept. **Its quality runs** lists them, as
`llmgw ctl eval --history` does.
- **Hold its updates to a quality suite…** — the suite and score its new
weights must reach before an update or a promotion moves it to them
(`llmgw ctl model gate`); **none** takes it away. With one set, **Update
to …** and **Promote the canary…** say so.
- **Why is it placed where it is** — `llmgw ctl explain-placement`.
- **Try again**, for a model parked after failing every start.
- **Remove** — its replicas stop and it leaves the catalog. A model from
`server.yaml` is changed there instead; the console says so.
**A** on Models adds one from a definition file on this machine: the YAML a
model takes in `server.yaml`.
**Replicas** lists every replica; on one, **Restart it**, replaced or in
place.
**Usage** is the last 24 hours by team, project or organization (→ changes
which): requests, tokens, errors and spend.
**Quotas** are capacity quotas: the GPUs, cores and memory each organization,
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
which models use it. **A** downloads a URL into it; **I** imports a model
from an Ollama on this machine into a mirror directory on this machine — the
mirror beside the control plane — and shows the model definition to add for
it (a mirror elsewhere: `llmgw mirror import-ollama` there); on a row, its files with
their checksums and where each was fetched from, the source a model
definition names for it, or remove it — with a warning when a model still
uses it.
**Jobs** lists the models being served or updated since the control plane
started: what each is doing now — the download's progress, a replica
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.
**Logs** shows a node's agent log, newest at the bottom, errors and warnings
coloured.
## People
The accounts, keys and organizations live in the store; these views read it
and change it with the same commands as `llmgw users`, `llmgw keys` and
`llmgw org`, so their checks and messages are the ones shown.
**Accounts** — who has signed up, their state, whether they see the cluster
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
**Forget them…** for a request to be forgotten (`llmgw users forget`).
**API keys** — every key, its project, owner, state and last use. **A**
issues one; the key is shown once, in the result, and never again. On a row:
its limits, **Rotate…** — a new key with the same project, owner and limits,
the old one working on for the overlap you give (`llmgw keys rotate`) — or
revoke it.
**Accounts** rows also have **Staff role…**:
- `viewer` looks at every page under /admin and changes nothing;
- `operator` also runs the cluster from them (models through the mirror, a
node's engines);
- `billing` sees usage and quotas;
- `admin` may do anything.
The STAFF column shows each person's role.
On **Usage**, **E** exports usage as CSV to a file on the control plane:
by hour, day or month and any of organization, team, project, user, key and
model.
**Audit** lists every change of the last week — through `ctl`, this console
or the /admin pages, and people's own keys and passwords — with who made
it, through what, and how it ended. Enter shows the fields it was given,
with anything secret taken out. `llmgw ctl audit` is the same, with filters.
**Organizations** — the tenancy tree: organizations, their teams, the teams'
projects, with members, keys and limits. **A** adds an organization. On a
row: add a team or project inside it, rename, move, limits for the level and
for each of its members, its capacity quota, and for a project how long
conversations and agent notes are kept, and whether its requests are
(**Keep requests…**: for how long, and what share), and whether they stay in
the cluster (**Requests stay in the cluster…**: never an outside provider),
and whether the same request is answered again from memory (**Cache
answers…**: for how long, and whether only reproducible answers or any). **Longest key lifetime…** caps how
long a key made there may live, and is the expiry of one made without any
(`llmgw keys ttl`; `auth.max_key_ttl` in server.yaml caps every key).
**Remove** takes away only something empty.
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:
- attached here (✓): Enter detaches it;
- applying from above (·): from everywhere, or from the organization or
team named;
- neither: Enter attaches it.
The **GUARDRAILS** column names those attached to the row itself. Policies
narrow like limits; see [guardrails.md](guardrails.md).
## Help
**Manual** lists every page there is: these, the installation and operation
guides, the configuration reference, a page for every command, the README
and the changes in each release. On a page:
| Key | Does |
|---|---|
| ↑ ↓, PgUp PgDn, Space | Scroll |
| g, G | The top, the end |
| T | Contents: the page's headings, Enter goes to one |
| / | Find; the lines holding it are marked |
| n, N | The next match, the one before |
| [ ] | The topic before, the one after |
| ← | Back to the list |
`llmgw docs` opens the same manual outside the console, on any machine,
installed or not; `llmgw docs install` opens one page; piped, it prints the
page's text.
## Settings
**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
- **Add a node** — installs llmgw on a machine over ssh, checks it, gives it a
certificate and starts its agent (`sudo llmgw node add`).
- **Replicate the store** — moves the store onto two more machines under
Patroni (offered only while it is not replicated).
- **Serve a model** — Hugging Face's models: trending, most downloaded,
most liked or newest (**S** changes which), or **/** to search. **F**
switches between GGUF files, which llama.cpp runs on the CPU or a GPU, and
original safetensors weights, which vLLM runs on GPUs. Enter on a
repository plans it against the cluster as it is, before anything is
fetched: the model's shape is read from one file's header, or its
config.json, and every file is listed with the best way it runs here — on
one card, on several of a node's cards, across several nodes' cards over a
fabric (original weights only), or on a CPU node — what each card or node
would hold, and whether that hardware is free now. The file that suits the
cluster best is first, marked ★; one that fits nowhere says so. Busy is not
unfit: which file is best follows the hardware the cluster has, not what it
is running, so a file for a card that is in use is still offered for it —
served, it waits for the card, or with a fallback runs elsewhere until then. Choosing a
file offers every way it fits, best first, and what to do when that one is
busy: the next best way, written as a lower-priority profile so placement
falls back to it, or wait. A vision model offers its projector too, fetched
beside it and loaded with it so the model reads images. What is chosen is
fetched into the mirror, checked, added to the catalog and started, and a
replica is asked to say hello (`llmgw ctl model serve`; the plan alone is
`llmgw ctl model plan`).
`mirror.registry` points it at another registry, for a site without the
internet.
- **Upgrade the cluster** to a release: this machine, then every node in turn
(`sudo llmgw ctl upgrade`).
- **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 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.
+121 -13
View File
@@ -16,20 +16,38 @@ who made a request, when, how many tokens, and what it cost.
Two features change that, and both are off until somebody turns them on for a
named project, and neither can be turned on without also saying for how long.
Nothing on the serving path leaves the cluster.
Nothing on the serving path leaves the cluster — **unless the catalog has a
model served outside it** (`external:` in a model definition): a request for
that model, or one that falls back to it, sends its prompt to that provider.
A guardrail policy with `no_store` refuses both for the scopes it covers.
**Guardrails** read every prompt and answer they are configured for, in the
gateway's own memory, to judge them. What they record — in the usage row and
the log — is the action, the policy, the category and how long it took: never
the text, the value that matched, or the placeholder map of a redaction. See
[guardrails.md](guardrails.md).
## What is stored, exactly
**Usage records**, one per request: the key, the project, the model asked for,
the model that answered, prompt and completion token counts, cost, HTTP status
and latency. No message content. The row is in `internal/store/store.go` as
`Usage` and there is no field for text in it.
`Usage` and there is no field for text in it. What the client chose to say
about the request is kept with it: OpenAI's `user` field and its `metadata`
or `x-llmgw-tags` — short strings, set by the client, for attributing spend.
A client that puts a person's name or address there puts it in the usage
records; the field is the client's, and so is that choice. Tags are not sent
on to the engine.
**API keys**, as SHA-256 hashes. The key itself is shown once, at creation, and
never stored — so a leaked database hands over no working credential, and
neither llmgw nor an administrator can recover a key somebody lost. A prefix is
kept so a key can be identified in a listing without revealing it.
**The store's own secrets**, like the one engine keys are derived from, plain
unless `secrets.master_key` seals them — with Vault's transit engine, so that
neither the store nor its backups hold them.
**People and tenancy**: accounts, the organisation/team/project tree, and the
limits attached to it. Passwords are hashed.
@@ -37,10 +55,34 @@ limits attached to it. Passwords are hashed.
**Conversations and agent notes** — only for a project that asked. See below.
**Kept requests** — only for a project that asked (`llmgw keys requests`):
what each request sent the model, after the guardrails' redaction and with
any stored conversation in it, and the answer the client got, for the hours
it said, all of them or a share. Never under a `no_store` policy.
**Batch files** — when a client uses the Batch API: the file of requests it
uploaded (prompts) and the files the batch wrote (answers), in the store,
until deleted (`DELETE /v1/files/{id}`) or, for what a batch wrote, until
`router.batch.retain_days` (30) pass. Only keys of the project that made
them can read them, and a project under a `no_store` policy cannot make
them.
**Reused answers** — only for a project that asked (`llmgw keys cache`):
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. 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.
## What is not stored
Prompts. Completions. Anything the model was shown or produced, unless the
project turned on conversations or notes.
project turned on conversations, notes or kept requests, or sent a batch.
(Reused answers are held in memory only, as above.)
The chat page in llmgw's own web interface keeps the conversation in a
JavaScript variable in the browser. Refresh it and the conversation is gone,
@@ -86,22 +128,62 @@ your own hardware, and the answer comes back. The router talks to engines, the
engines talk to nothing, the agents talk to the control plane. All of it is
inside the cluster.
Two things can reach outside, both started by an operator rather than by a
request:
Around the serving path, a cluster set up the ordinary way does reach out.
None of it carries a prompt, an answer, a key or a user — it is software and
public model files coming in — but a review should know each one:
**Fetching weights.** When a model's `source` is a URL, the agent downloads it.
Point that at an internal mirror and nothing leaves at all — which is also the
**Installing engines.** Each node's agent downloads the llama.cpp build for its
hardware from GitHub, checked against the checksum GitHub publishes, and vLLM
(SGLang when asked) from PyPI, with `uv` from GitHub. On every node, when it
joins and when the engine version changes.
**Releases.** `llmgw node add`, `llmgw ctl upgrade` and `llmgw upgrade` fetch
llmgw's own release binaries from the project's release server, checked against
its signed checksums.
**Fetching weights.** When a model's `source` is a URL, the agent downloads it;
the mirror fetches what it is asked to, from Hugging Face or another URL, and
`server init` has it fetch the starter model and the guardrails' judge. Point
sources at the mirror and the nodes fetch only from it — which is also the
faster arrangement, because eight nodes pulling forty gigabytes from the
internet is a day's work and from a local mirror it is minutes. A `source` that
is a path on disk is never fetched.
**`llmgw ctl plan`.** Reads a model's architecture from huggingface.co to work
out whether it fits your hardware. An operator command run at planning time,
not part of serving, and not run by anything automatically.
**The registry.** With a mirror configured, the control plane asks Hugging Face
every six hours whether the models served from it have newer files
(`mirror.update_check`), and `/admin/mirror` searches it. `llmgw ctl plan`
reads a model's architecture from huggingface.co to work out whether it fits
your hardware.
There is no telemetry, no licence check, and no call home. The binary is
static, built with `CGO_ENABLED=0`, and runs in a network with no route to the
internet as long as the weights are already on disk.
There is no telemetry, no licence check, and no call home.
### A cluster with no way out
A site with no route to the internet installs from an **offline bundle**
instead, and then nothing at all leaves the cluster (docs/install.md, "Offline
sites"). The bundle is made on a machine that has internet — llmgw's release
binaries with their signed checksums, the llama.cpp builds with the checksums
GitHub publishes, the model files with the checksums Hugging Face publishes,
each checked as it is downloaded — carried in, and imported into the mirror.
With `offline.mirror` set in `server.yaml`, nodes install engines from the
mirror and never from GitHub, releases come from the mirror, and the registry
is not asked about newer weights.
Verification is not weakened by any of this. A release is held to the project's
signing key wherever it is fetched from. An engine file is held to the checksum
recorded when the bundle was made, which the control plane keeps and hands each
node over the agent's authenticated connection: the mirror serves plain HTTP,
and nothing a node runs is taken on the mirror's word. The bundle itself is
checked against the sha256 its maker printed, carried to the site apart from
the bundle.
What does not come in a bundle: vLLM and SGLang, which are Python environments
from PyPI — an offline node serves with llama.cpp unless one is installed on it
by hand — and the operating system's own packages (the OpenMP runtime the
engine needs, PostgreSQL when chosen), which come from the machines' package
sources: at an offline site, the distribution mirror it already has. Searching
the registry from `/admin/mirror` and `llmgw ctl plan` need the internet and
do not work offline; `mirror.registry` can point the first at an internal copy.
## What the software cannot do for you
@@ -131,3 +213,29 @@ touch, whatever the catalog says.
| Scope is part of the lookup | `internal/store/threads.go`, `Thread` |
| Deletion is a client endpoint | `internal/router/threads.go`, `handleDeleteThread` |
| Weight fetching is the only serving-path egress | `internal/agent/weights/fetch.go` |
## Forgetting a person
`llmgw users forget <email> --yes` (or **Forget them…** in the console) is for
a request to be forgotten:
- their usage — every request record and every sum — is kept, so totals and
budgets stay true, but under a random pseudonym that links to nothing;
- their kept requests (`keys requests`), personal files and batches, and
their own limits are deleted;
- their email and id are taken out of the audit log, which keeps what was
done;
- then the account goes, as `users delete` removes it: sessions ended, keys
revoked and left without an owner.
Conversations and agent notes belong to projects, not to the people in them,
and are not touched: a project forgets them by its retention, and a thread by
`DELETE /v1/threads/{id}`. Backups taken before still hold everything until
they age out; that is what their retention is for.
## Keeping a project's requests in the cluster
`llmgw keys residency --project <path> --in-cluster` refuses the project's
keys any model served by an outside provider, and never uses one for them as
a fallback or a canary: their prompts do not leave the machines the cluster
runs on. `--anywhere` lifts it.
+6 -5
View File
@@ -1,8 +1,9 @@
# Quickstart: one machine, one model, ten minutes
# Development: llmgw from source on one machine
Written for someone who wants to see llmgw answer a request on a single
workstation before reading anything else. No systemd, no `sudo`. For a real
cluster, see [install.md](install.md).
Written for a developer who wants to see llmgw answer a request on a single
workstation, built from source, with no systemd and no `sudo` — for working on
llmgw itself. **To install llmgw on servers, read
[quick-install.md](quick-install.md) instead.**
What you end up with: an OpenAI-compatible endpoint on `localhost:8080` in front
of a real model served by llama.cpp.
@@ -26,7 +27,7 @@ make test
./bin/llmgw --version
```
`make build` writes `./bin/llmgw`. Go 1.26 or later is the only requirement.
`make build` writes `./bin/llmgw`. Go 1.26.6 or later is the only requirement (an older Go fetches it itself).
## 2. Check the path with a fake engine
+210
View File
@@ -0,0 +1,210 @@
# InfiniBand fabrics and NVLink
For a site whose GPU nodes are joined by InfiniBand or RoCE, by NVLink, or both
— several independent fabrics, NVL72 racks, eight-GPU HGX boards. It says what
llmgw does with them, what it needs from you before it can, how to check a node
in one command, and where to look when a replica spanning nodes will not run.
Read [install.md](install.md) first; this is the part of it that only matters
once there is a fabric.
> **Where this stands.** Every piece here was written against the real formats
> of `sminfo`, `saquery`, `ibnetdiscover`, `nvidia-smi topo -m` and
> `nvidia-smi -q`, and tested against them — but none of it has yet run on a
> site with the hardware. Run `llmgw doctor --json` on two or three nodes first
> and keep the reports: if a parser meets a format it does not know, that report
> is what fixes it, and the fix is a control plane upgrade, not one per node.
## What llmgw does with them
**Two kinds of interconnect.** InfiniBand or RoCE joins nodes (scale-out).
NVLink joins GPUs — inside one machine on an HGX board, or across the nodes of a
rack on an NVL72 or an NVLink Switch system (scale-up). A replica spanning nodes
is placed, in order of preference:
1. inside one **multi-node NVLink domain**, if any has enough usable nodes:
tensor parallelism between its nodes then runs over NVLink;
2. on one **physical InfiniBand or RoCE fabric**, preferring nodes under one
leaf switch and putting nodes with a degraded cable last.
It is never split across two fabrics or two NVLink domains. Within a node, the
GPUs a shard gets are the set with the most NVLink pairs, then the nearest to
the fabric's adapters, then on one NUMA node.
**A fabric has a name and an identity.** The name is what `agent.yaml` says; the
identity is what the adapters report — on InfiniBand the subnet managers every
port on one fabric sees, on RoCE the IP network of its RoCE v2 address. The name
is how you address a fabric; the identity is how llmgw catches the name being
wrong: two independent fabrics given one name are placed as two, and the
cluster page says so.
## Before a node joins
On every GPU node:
| What | Why | Check |
|---|---|---|
| NVIDIA driver | everything | `nvidia-smi` |
| `nvidia-fabricmanager`, on an NVSwitch machine | without it CUDA does not start on those GPUs | `systemctl is-active nvidia-fabricmanager` |
| `rdma-core`, `ib_uverbs` loaded | user-space RDMA at all | `ls /sys/class/infiniband`, `lsmod \| grep ib_uverbs` |
| `nvidia_peermem` loaded | GPUDirect RDMA; without it NCCL copies every transfer through host memory | `lsmod \| grep nvidia_peermem` |
| `infiniband-diags` | tells llmgw which fabric a port is on; the agent installs it if missing | `which sminfo` |
On an NVL72 or NVLink Switch system, also:
| What | Why | Check |
|---|---|---|
| `nvidia-imex` running, every node of the domain in `/etc/nvidia-imex/nodes_config.cfg` | GPUs in different nodes share memory through it | `systemctl is-active nvidia-imex` |
| an IMEX channel (`options nvidia NVreg_CreateImexChannel0=1`) | an engine opens one | `ls /dev/nvidia-caps-imex-channels` |
| every GPU joined to its domain | the fabric manager does this | `nvidia-smi -q \| grep -A6 Fabric` shows `State: Completed` |
On each InfiniBand fabric:
- **A subnet manager, and a standby.** Without one, ports come up to `INIT` and
stay there; without a standby, the fabric keeps its routes if the master
stops but cannot recover from any change after. llmgw watches this but does
not run one.
- **Its own name** in `agent.yaml` on every node attached to it. Optionally, a
distinct `subnet_prefix` in each subnet manager's configuration, which makes
GIDs unique across fabrics; llmgw does not need it.
## Declaring the fabrics
In `/etc/llmgw/agent.yaml`, the adapters on each fabric, by name:
```yaml
fabrics:
- name: ib-compute
hcas: [mlx5_0, mlx5_1, mlx5_2, mlx5_3, mlx5_6, mlx5_7, mlx5_8, mlx5_9]
- name: ib-storage
hcas: [mlx5_4]
```
or `--fabric ib-compute:mlx5_0,mlx5_1,…` when onboarding ([node-onboarding.md](node-onboarding.md)).
An adapter belongs to one fabric; a node may be on several. A replica on
`ib-compute` is given only `ib-compute`'s adapters (`NCCL_IB_HCA`).
The rate is measured, not taken from the file: a fabric's rate on a node is the
slowest of its active adapters, because a collective runs at the speed of its
slowest link.
The agent checks the declaration against the adapters on every heartbeat. An
adapter that is missing, or whose port is down, takes that fabric off the node
within seconds — the node stops being offered multi-node work on it — and
returns it when the port comes back.
## Checking a node: `llmgw doctor`
```
sudo llmgw doctor # uses /etc/llmgw/agent.yaml when present
sudo llmgw doctor --json > $(hostname)-doctor.json
```
It reads the node and says what is wrong and how to fix it, most serious first,
and exits non-zero when anything fails:
```
FAIL nvlink this machine has NVSwitch but nvidia-fabricmanager is inactive: CUDA will not initialise on its GPUs
→ systemctl enable --now nvidia-fabricmanager (its version must match the driver's)
WARN fabric nvidia_peermem is not loaded: GPUDirect RDMA is off, and NCCL copies every transfer through host memory
→ modprobe nvidia_peermem
OK gpu 8 GPUs: 8× NVIDIA H100 80GB HBM3
OK nvlink NVLink groups: [0 1 2 3 4 5 6 7]
OK fabric mlx5_0/1 ACTIVE at 400 Gb/s (InfiniBand), subnet manager 0x248a07030030f0c0 (LID 1, SMINFO_MASTER)
OK fabric subnet managers of 0x248a07030030f0c0's fabric: 0x248a07030030f0c0 (master, priority 15), 0x248a07030030f1d4 (standby, priority 14)
OK agent fabric ib-compute: mlx5_0,mlx5_1,… usable at 400 Gb/s
OK agent NCCL bootstraps over ens3 (10.1.0.21)
```
followed by each GPU's NVLink peers and nearest adapter, each port's state,
rate and errors, and the NCCL environment a shard on each fabric would be given.
`--json` writes every fact it read as well as what it concluded — send that
file when the report is not enough.
## Checking two nodes: `llmgw ctl fabric-test`
Every check on one node can pass while two nodes still cannot reach each other
— a cable into the wrong switch, a partition key, a RoCE network with no route
— and a replica spanning them then hangs on its first all-reduce. Before
placing one, on the control plane:
```
llmgw ctl fabric-test gpu01 gpu02
```
```
gpu01 → gpu02 on ib-compute, 41s
RAIL LINK MEASURED VERDICT DETAIL
mlx5_0 → mlx5_0 400 Gb/s 388.2 Gb/s ok 97% of 400 Gb/s
mlx5_1 → mlx5_1 400 Gb/s 241.0 Gb/s slow 60% of 400 Gb/s: check the cable, and the card's PCIe width and NUMA node
mlx5_2 → mlx5_2 400 Gb/s 0.0 Gb/s fail gpu01: ib_write_bw: Couldn't connect to 10.1.0.22:18517
```
It runs `ib_write_bw` (perftest, installed on a node the first time) from each
of the first node's adapters on the fabric to the same adapter on the second —
mlx5_0 to mlx5_0, the way NCCL pairs them — one rail at a time, through the
agents, so no SSH between nodes is needed. Each rail is graded against its
link's rate: **ok** at 80% or more, **slow** at 50%, **fail** below that or when
nothing got through. `--fabric` picks one when the nodes share several,
`--seconds` the length of each rail's run. The traffic is real: run it when the
fabric is quiet.
It measures RDMA between adapters, not GPUDirect or NCCL itself: a pass says
the fabric carries traffic between these two nodes at its rate, which is what
a first all-reduce needs and what nothing on one node can show.
## Watching them
- **Cluster page, Fabrics.** Each declared fabric and what it physically is;
a table of subnet managers per fabric — master, priority, standbys, the last
change of master — and every disagreement: one name on two fabrics, two names
on one, an adapter up but undeclared, ports up with no subnet manager, a
fabric with no standby, a recent failover.
- **`/admin/fabrics`.** Each fabric's map, from `ibnetdiscover` run by one of
its nodes every five minutes: switches in their tiers, adapters under their
leaves, cables coloured by rate, degraded ones in red, rail alignment, and
the cluster's own adapters live with their traffic. Click anything for its
detail.
- **`/admin/nvlink`.** Each multi-node NVLink domain as a rack of trays: which
GPUs are joined, which node's IMEX is down, which link runs degraded.
- **A node's page.** Its ports with state, rate, errors and what they are
physically on; its GPU-by-GPU NVLink matrix; its load.
- **Metrics**, for alerts:
| Metric | Alert when |
|---|---|
| `llmgw_ib_port_active` | a declared port is 0 |
| `llmgw_ib_port_counter_total{counter="symbol_error"}` and `link_downed` | rising |
| `llmgw_ib_port_rate_gbps` | below its usual rate |
| `llmgw_fabric_subnet_managers{state="standby"}` | absent or 0 |
| `llmgw_fabric_master_sm` | its `sm_guid` changes (a failover) |
| `llmgw_fabric_problems{level="fail"}` | above 0 |
| `llmgw_gpu_nvlink_joined` | 0 |
| `llmgw_imex_up` | 0 |
## What an engine is given
For a shard on an InfiniBand or RoCE fabric:
| Variable | Value |
|---|---|
| `NCCL_IB_HCA` | that fabric's adapters on this node, and no others |
| `NCCL_IB_DISABLE` | `0` (`1` for a single-node shard) |
| `NCCL_SOCKET_IFNAME`, `GLOO_SOCKET_IFNAME` | the node's management interface: `mgmt_interface`, or the one holding its management address |
| `NCCL_IB_GID_INDEX` | on RoCE only: the RoCE v2 entry with an IPv4 address |
| `NCCL_MNNVL_ENABLE`, `NCCL_CUMEM_ENABLE` | `1`, for a replica inside a multi-node NVLink domain |
A profile's `env` overrides any of them. vLLM launches a multi-node replica
with its own multiprocessing (`engines.vllm.launch_mode: mp`, the default):
every node runs `vllm serve` with its rank and the leader's address, the
workers headless. `launch_mode: ray` is there for a vLLM too old for that.
## When a replica spanning nodes will not run
| It | Look at |
|---|---|
| is not placed | `llmgw ctl explain-placement <model>`: a fabric split by identity, a node whose IMEX is down, a node refused because its adapters are cabled to two fabrics |
| starts and hangs on its first collective | `llmgw ctl fabric-test` between two of its nodes; the cluster page's fabric problems; `doctor` on each node — a port down, the wrong interface, RoCE without a v2 GID |
| runs, slowly | `nvidia_peermem`; the map for a degraded cable; the node page's NVLink matrix for a group that crossed PCIe |
| worked yesterday | the subnet manager table for a failover; `link_downed` on the node's ports |
+400
View File
@@ -0,0 +1,400 @@
# Facility-aware placement: rack power, temperature and cooling
**Status: steps 0, 1 and 2 are built, with the manual hold of step 4;
temperature conditions (steps 3 and 4) and shedding (step 5) are not**,
until the thermal data of a real site is at hand — without it thresholds would
be designed against guesses. This is the design for step 5 of resource
allocation, after reserved CPU cores and memory (1), capacity classes and
preemption (2), tenant capacity quotas (3) and sizing by measured throughput
(4). [What is built](#what-is-built) says what works today and where it
departs from the design below; the rest of the document is the design.
## What is built
**Where a node is.** Five labels: `llmgw.io/site`, `room`, `row`, `rack` and
`pdu` (a comma separated list, for a dual-fed rack). They come from the node's
`agent.yaml` under `labels:`, or from the control plane:
```
llmgw ctl label gpu01 llmgw.io/rack=r12-07 llmgw.io/pdu=r12-07-a,r12-07-b
llmgw ctl label gpu01 llmgw.io/rack- # back to agent.yaml's value, if any
```
Labels set with `ctl label` are kept in the store, win over the agent's, and
may be set before the node joins. A misspelt `llmgw.io/` key is refused. A
domain is named `level:name` — `rack:r12-07`, `pdu:r12-07-a` — and names are
taken as given, so two rooms that both have a rack `r1` must label them apart.
A node without location labels is in no domain and places as it always did.
**Spreading.** A model's next replica goes first to a node sharing no rack and
no PDU with the nodes the model already runs on; nodes sharing fewer come
before nodes sharing more. It is an ordering, never a filter: a rack holding
the only free cards still takes the replica. It ranks above preferring a node
that already holds the weights — a second replica one breaker away from the
first is worth less than the copy saved — and above bin packing.
**Keeping a replica together.** A profile's `keep_within: rack` (or `row`,
`room`, `site`) keeps a multi-node replica's nodes in one such domain, inside
the fabric or NVLink domain placement already requires; nodes without the
label are passed over for it, with the reason.
**Holds.** `llmgw ctl facility hold rack:r12-07 --reason "PDU swap"` places
nothing new in the domain, as a cordon of each of its nodes would, including
nodes labelled into it later. `--drain` also drains its nodes, one replica at
a time with the replacement up first; `llmgw ctl facility release` undoes
both. Holds are kept in the store across restarts (the drain itself is node
state, which is not: after a restart the hold still keeps work out, and
replicas not yet moved stay where they are). Any domain can be held: a PDU
holds every rack on it.
**Power budgets.** In `server.yaml`:
```yaml
facility:
margin: 0.1 # share of every cap kept free
gpu_watts: 700 # a card that reports no power limit
cpu_core_watts: 10 # a core reserved by a CPU replica (cpu_cores)
node_watts: 350 # each node's own draw beyond its cards
budgets:
- domain: rack:r12-07
cap_watts: 16000 # what one feed carries alone, derated
other_watts: 800 # switches, storage
- domain: pdu:r12-07-a
cap_watts: 7400
```
A replica's draw is the profile's `power_watts` (split over its nodes) when
declared; otherwise each card at the power limit it reports through the agent's
sensors (or `gpu_watts`), and each reserved core at `cpu_core_watts`. A
domain's use is, node by node, `node_watts` plus the larger of what is placed
there and what the node's sensors read in the last two minutes
(`measured_fresh`), plus the domain's `other_watts`. A shard that would take
any of its node's domains past `cap × (1 − margin)` is refused; a multi-node
replica's shards are counted together, so two that each fit a rack alone do not
both go into it. A draw nobody knows — no declared figure, no card limit, no
`gpu_watts` — counts as nothing: missing data never blocks.
**What people see.** `llmgw ctl explain-placement` names the domain: *rack
r12-07 is held: PDU swap*, *rack r12-07: 14.2 kW of a 14.4 kW limit (16.0 kW
cap less 10%) in use, the shard needs ~2.8 kW*. The rejections of a placed
replica are kept too, so a replica in an unexpected rack says why. `llmgw ctl
nodes` has a LOCATION column, shows *ready, held*, and filters with `--domain
rack:r12-07`. `llmgw ctl facility` lists every domain with its nodes, use,
limit, reserved and measured draw, and holds. The console's Nodes view has a
WHERE column; a node's page shows its location and hold, and its menu sets
the location (**Location…**) and holds or releases its rack. The admin API:
`GET /api/v1/facility`, `POST /api/v1/facility/holds`, `DELETE
/api/v1/facility/holds/{domain}`, `POST /api/v1/nodes/{id}/labels`.
**Where it departs from the design below, and why.**
- Domains come from labels alone; there is no facility file declaring them.
Budgets are in `server.yaml`, the one file the control plane already reads.
A DCIM export would generate that block.
- Feeds are not modelled. The cap given is what one feed carries alone,
derated — the figure the design says matters — so the operator gives the
one number instead of llmgw deriving it from two.
- The manual hold of step 4 is built now, without conditions: it needs no
sensor data and answers the commonest facility event, planned maintenance.
Its command is `ctl facility hold`, with a `--drain` the design did not have.
- `keep_within` is new: the request that a multi-node replica stay in one rack
where the cabling requires it.
- Spreading is an ordering of candidates, not the scoring of the design; power
headroom does not yet order racks ("level load across racks"), and
multi-node replicas are not spread across racks.
- No `llmgw_domain_*` metrics or alerts yet; `ctl facility` and the API carry
the same figures.
## Why
In a large installation the scarce thing is often not a GPU but the room it
sits in. Three failures are invisible to placement today:
- **A rack goes over its power budget.** Every node in it reports healthy;
placement sees free cards and fills them; the breaker, or the facility's
power cap, decides the rest. A rack with A and B feeds is worse: it runs
fine until one feed fails and the other cannot carry the load.
- **Hot hardware is slow hardware.** GPUs throttle long before they fail. A
replica placed in a rack whose inlet air is too warm serves fewer tokens per
second than its measured capacity says, and nothing connects the two.
- **Correlated failure.** Two replicas of a model on two nodes in the same rack
or on the same PDU are one failure away from zero. Placement spreads replicas
across nodes, not across racks.
The goal is for placement to treat facility limits the way it already treats
memory and cores: reserve against a budget, refuse what does not fit, say why,
and never let missing data stop the cluster.
## What it builds on
| Already in place | Used for |
|---|---|
| Node labels, reported by agents, matched by `node_selector` | Where a node is: its rack, row, PDU |
| `inventory.Allocation` carries GPUs, memory and cores per shard | Adding watts, summed per rack and PDU |
| Placement rejections (`explain-placement`, headroom's "why not") | Naming the rack that is full or hot |
| Headroom: the scheduler dry-run on a copy of the inventory | Room that respects power budgets, for free |
| Preemption with classes, minimums and a dry run (step 2) | Shedding load from a hot rack in the right order |
| Measured throughput per profile (step 4) | Tokens per watt as a placement preference |
| Node sensors, read by every agent (step 0, built) | Heat and power with no facility integration: see below |
| `internal/promq`, a Prometheus client | Reading facility metrics that DCIM exporters publish |
## Concepts
**Location.** Each node has a place in a tree: site, room, row, rack. Power has
its own tree beside it: a rack draws from one or more PDUs, and each PDU from a
feed. Both are read from node labels with well-known keys:
```yaml
# agent.yaml
labels:
llmgw.io/site: sto1
llmgw.io/room: hall-b
llmgw.io/row: "12"
llmgw.io/rack: r12-07
llmgw.io/pdu: r12-07-a,r12-07-b # both feeds, for a dual-fed rack
```
A node without these labels is its own rack of one: placement behaves as it
does today.
**Domain.** A rack, a row, a PDU, a room: anything with a budget or a
condition. Domains are declared in a facility file (below) or discovered from
node labels; a label naming a domain the file does not declare creates it with
no budget and no condition.
**Budget.** A domain may have a power cap in watts. Placement reserves each
replica's estimated draw against every domain its node belongs to and refuses a
placement that would take any of them past its cap less a safety margin. For a
dual-fed rack the cap that matters is what one feed can carry alone, since that
is what the rack must survive.
**Condition.** A domain has a thermal and cooling condition: `ok`, `warm`, `hot`
or `unknown`, derived from readings against thresholds with hysteresis.
Conditions change what placement prefers and allows; they are not amounts to
fill.
**Reading.** A measured value with a time: inlet temperature, power draw, a
cooling unit's alarm state. Readings older than their staleness limit are
treated as absent.
## Estimating a replica's draw
What placement reserves has to be known before the replica runs, so it is an
estimate first and a measurement later:
1. **Declared.** A profile may say `power_watts`. The operator's figure wins.
2. **Derived.** Otherwise GPU profiles take each card's power limit from NVML
(the agent already reads the cards; the power limit is one more field) plus
a per-node share for CPU, memory and fans; CPU profiles take their reserved
cores times a per-core figure for the node's CPU model.
3. **Measured.** Once replicas run, the agent reports their cards' actual draw
(NVML) and, where the BMC allows, the node's (Redfish or IPMI DCMI). A
domain's headroom is computed from the larger of what is reserved and what
is measured, so an estimate that was too low is corrected by the room it
stops offering, not by an outage.
## Decisions
### Power
- **Hard limit.** A placement that would take any domain past `cap × (1 −
margin)` is rejected, with the domain and the figures: *rack r12-07: 14.2 of
16.0 kW reserved, replica needs ~2.8, margin 10%*.
- **Feeds.** For a domain with two feeds the cap is the smaller feed's rating
times its derating (typically 80%), not the sum.
- **Already over.** A domain found over its cap, because the estimate was low
or the cap was lowered, gets no new placements, and an alert. It is not
emptied automatically; shedding power is the facility's decision, and
stopping inference to do it is ours only if asked (see shedding below).
### Temperature and cooling
Thresholds are per domain with defaults, and each condition needs to hold for a
while before it changes, in both directions, so a single reading neither
starts nor ends anything:
| Condition | Enters when | Placement |
|---|---|---|
| `ok` | below the warm threshold for 5 min | normal |
| `warm` | inlet above the warm threshold for 2 min, GPU temperatures near throttling, or any GPU thermally throttled | allowed, scored down: other domains first |
| `hot` | inlet above the hot threshold for 2 min, a cooling unit in alarm, or a sustained share of the domain's GPUs thermally throttled | no new placements, as a cordon would, but automatic and reversible |
| `unknown` | no fresh reading | as `ok`, and reported as unknown |
**Shedding** (optional, off by default): a domain `hot` for longer than its
shed delay has load moved off it using step 2's machinery. The lowest class goes
first, and replicas above their model's minimum go before any at it. A critical
minimum is never touched, and moves are rate limited to one replica per domain
per interval. The replica is placed elsewhere first where there is room, so
shedding moves work rather than dropping it. GPU power capping (`nvidia-smi
-pl`) is a gentler alternative worth measuring against shedding: less
throughput, no restart.
### Spreading
Replicas of one model are spread across racks, and across PDUs, the way they
are spread across nodes today. This is a preference, dropped when it is the only
thing standing in the way, as node spreading already is.
### Scoring
Placement today filters, then takes the first candidate in a fixed order
(fewest free GPUs for GPU profiles, fewest replicas for CPU ones, nodes holding
the weights first). Facility signals are better as a score than as more
filters. Candidates that pass the hard limits are ordered by:
1. condition: `ok` before `warm`;
2. power headroom left in the rack after this replica, more first, so load
levels across racks;
3. the existing order.
Once measurements include power, step 4's profile preference can use tokens per
watt instead of tokens per GPU where both are known.
## Where the data comes from
A `FacilitySource` interface, implemented several ways, chosen in
`server.yaml`:
```go
type FacilitySource interface {
// Domains is what the source knows: budgets, thresholds, members.
Domains(ctx) ([]Domain, error)
// Readings is the latest value of each signal, per domain.
Readings(ctx) ([]Reading, error)
}
```
- **Static file.** Domains, caps and thresholds, written by hand or generated
from a DCIM export. Enough for power budgets with no live data at all.
- **Prometheus.** DCIM, PDU (SNMP exporter) and BMC (Redfish exporter)
readings usually end up in Prometheus. The source runs configured instant
queries and maps their labels onto domains:
```yaml
facility:
source: prometheus
prometheus: http://monitoring:9090
signals:
inlet_temp_c:
query: max by (rack) (dcim_rack_inlet_temperature_celsius)
domain_label: rack
stale_after: 2m
power_watts:
query: sum by (pdu) (pdu_outlet_power_watts)
domain_label: pdu
cooling_alarm:
query: max by (row) (crac_alarm)
domain_label: row
```
- **Agents (built, step 0).** What each node reads about itself, every ten
seconds at most, sent with its heartbeat:
- **GPUs, from nvidia-smi:** temperature, memory (HBM) temperature where the
driver reports it, power draw, the enforced power limit, and what is
holding the clocks down now — `sw_power_cap`, `hw_slowdown`, `sw_thermal`,
`hw_thermal`, `power_brake`.
- **CPUs, from hwmon** (what lm_sensors reads, without it installed): the
package temperature, or the hottest core; and the board's inlet or ambient
sensor where it has one (`SYSTIN`, `Inlet`, `Ambient`).
- **CPU power, from RAPL:** the packages' energy counters over time,
subdomains not counted twice, counter wrap handled.
A machine without a sensor reports nothing for it, never a zero; a VM has
none of them. Published as `llmgw_gpu_temperature_celsius`,
`llmgw_gpu_memory_temperature_celsius`, `llmgw_gpu_power_watts`,
`llmgw_gpu_power_limit_watts`, `llmgw_gpu_throttled{reason}`,
`llmgw_node_cpu_temperature_celsius`, `llmgw_node_cpu_power_watts` and
`llmgw_node_inlet_temperature_celsius`, and on each node's page.
What they can and cannot say:
- **Throttling is the strongest signal there is.** A temperature is a proxy;
a card holding its clocks down is the fact that invalidates its measured
capacity, and it needs no threshold to be chosen.
- **Measured power** is GPU draw plus CPU packages; memory, disks, fans and
power-supply losses are not in it, so a node's draw is that plus an
estimated remainder. A rack's is its llmgw nodes' plus a declared figure
for everything else in it: switches and storage report nothing here.
- **CPU and GPU temperatures follow the load, not the room.** A hot rack is
inferred from many nodes running hot at comparable load, or from
throttling, not from one node's CPU. Only a board inlet sensor reads the
air, and most boards have none.
- **No budget comes from a sensor.** A reading is a draw; the cap it is
measured against still comes from the facility.
- **Simulated.** Readings from a script, for tests and for rehearsing a
thermal event on a cluster without facility data.
## Safety
- **Missing data never blocks.** A source that is down, a reading that is stale,
a node without location labels: placement behaves as it does today, and says
the data is missing (a metric, a line on the cluster page). Facility
awareness can make placement more careful, never impossible.
- **Hysteresis and hold times** on every condition, so a noisy sensor does not
flap a rack between allowed and forbidden.
- **Observe-only first.** A mode where every decision is computed, logged and
shown ("would have refused: rack r12-07 hot") but nothing is enforced, run
for long enough to trust the thresholds before turning enforcement on.
- **Manual override.** `llmgw ctl facility hold RACK` and `release RACK`, for the
facility team to mark a domain unavailable, or to clear a condition they know
to be a sensor fault.
- **Shedding is opt-in**, per domain or cluster-wide, and rate limited.
## What people see
- `explain-placement` and headroom name the domain: *rack r12-07 is hot (inlet
31°C for 6 min)*, *pdu r12-07-a: 7.1 of 7.4 kW*.
- `llmgw ctl facility` lists domains with budget, reserved, measured and
condition. The cluster page gets a rack summary beside the nodes table.
- Metrics: `llmgw_domain_power_budget_watts`, `_reserved_watts`,
`_measured_watts`, `llmgw_domain_condition{condition}`,
`llmgw_domain_reading_age_seconds`; alerts for a domain near its cap, a
domain hot, a domain over its cap, and facility data gone stale.
## Rollout
Each step ships on its own and is useful without the next:
0. **Node sensors, observe-only (built).** GPU heat, power and throttling,
CPU heat and power, inlet air where the board has a sensor; published and
shown, no decision changed.
1. **Location and spreading (built).** Well-known labels, domains from labels, replicas
spread across racks and PDUs, locations on the node views. No facility data
needed.
2. **Power budgets from a static file (built, without headroom
scoring).** Caps and margins, estimated draw per
replica (declared or derived), hard limit in placement and headroom.
3. **Readings, observe-only.** The Prometheus source beside the agents',
conditions with hysteresis (throttling first among them), measured power
folded into headroom; decisions shown, not enforced.
4. **Thermal enforcement.** `warm` scored down, `hot` refused; the manual hold
and release are built.
5. **Shedding and tokens per watt.** Opt-in shedding through step 2's
preemption; measured power in profile preference.
## Testing
- The simulated source drives unit tests of every condition transition, the
hysteresis, staleness, and the placement outcomes, with mutation checks as
for steps 1–4.
- Property tests: whatever the readings, placement never exceeds a declared
cap with fresh data, and never refuses everything because data is missing.
- On the PoC cluster, with no facility data: step 1 with labelled VMs, step 2
with a static file of made-up caps, steps 3–4 with the simulated source
replaying a thermal event.
- Before enforcement anywhere real: a period in observe-only mode against the
site's actual data.
## Open questions
- **What the sites have.** Which DCIM, which PDUs, which BMCs, and whether any
of it already reaches a Prometheus. The source design assumes Prometheus is
the common ground; a site without it needs another source.
- **Who owns the numbers.** Caps, deratings and thresholds belong to the
facility team. Whether they edit llmgw's file, or llmgw reads theirs.
- **Phase balance.** Three-phase PDUs have per-phase limits; whether placement
should see phases or only the PDU total.
- **Cooling granularity.** Cooling is often per row or per aisle, not per rack;
the domain tree allows either, but the defaults should match what sites
actually measure.
- **Power capping instead of shedding.** Whether to lower GPU power limits in a
warm rack rather than move replicas, and how that interacts with measured
capacity from step 4, which would no longer hold at the lower limit.
+390
View File
@@ -0,0 +1,390 @@
# Guardrails
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 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
guard:
# Applied to every request.
default: [baseline]
policies:
baseline:
input:
pii: {secret: block, card: redact}
injection: flag
output:
pii: {secret: block}
finance:
mode: enforce # enforce (default), shadow, off
fail: closed # open (default) or closed, for checks that can fail
no_store: true # no conversations, notes, prefix routing or external models
max_output_tokens: 2048
system_prompt: "You answer questions about Acme's products only."
drop_client_system: true
stream: sentence # sentence (default) or buffer_all
require_capabilities: true # no tools/images to models whose catalog entry lacks them
validate_structured: true # answers must fit response_format's json_schema
input:
pii: {email: redact, phone: redact, iban: block, tckn: redact}
injection: block
max_chars: 40000
deny:
- {name: codename, regex: "(?i)project falcon", action: block}
output:
pii: {iban: redact, tckn: redact, secret: block}
tools:
allow: [search, fetch]
deny: [shell]
on_denied: strip # strip (default) or block
max_calls_per_turn: 4
validate_arguments: true
```
**What is found.**
- **Personal data:** email, phone, iban, card, tckn, ip. Each is checked
before it counts: IBANs by their mod-97 check, cards by Luhn and issuer
prefix, TCKNs by their two check digits, phone numbers only when written
to be dialled (`+90…`, `00…`, `0532…`).
- **Secrets** (`secret`): OpenAI- and Anthropic-style keys, AWS access key
ids, GitHub, Slack and Google tokens, JWTs, PEM private keys and llmgw's
own keys.
- **Prompt injection:** instructions to drop instructions (English and
Turkish), chat-template control tokens in user text, invisible Unicode,
long encoded blobs. These are found through disguises: fullwidth letters,
Cyrillic look-alikes, zero-width characters, and base64, hex and URL
encoding two levels deep.
- **`deny` patterns:** matched against the text as written, and as a model
reads it once those disguises are removed.
**What is done.**
- **`flag`** lets the request through and records it.
- **`redact`** rewrites the value as a placeholder (`[EMAIL_1]`; the same
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.
- `drop_client_system` removes the caller's own system and developer
messages.
- `max_output_tokens` is set where a request asks for none, and lowers one
that asks for more.
- A tool `deny`/`allow` list strips the tool from the request, or refuses
it.
- In the answer, a call to a tool that is not allowed, more calls than
`max_calls_per_turn`, or arguments that do not fit the parameters the
request declared (`validate_arguments`) stop it.
- `require_capabilities` refuses tools offered to a model whose catalog
entry does not say `capabilities.tools`, and images to one without
`capabilities.vision`. It is opt-in because an entry that says nothing is
read as "cannot".
- `validate_structured` checks an answer against the `json_schema` the
request asked for in `response_format`. Buffered, or streamed with
`buffer_all`, an answer that does not fit is stopped; streamed a sentence
at a time it has gone out already, and is flagged.
## A judge model
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: qwen3guard-0.6b
source: http://mirror:8081/qwen3guard-0.6b/…/Qwen3Guard-Gen-0.6B.Q8_0.gguf
role: guard
guard:
format: qwen3guard # qwen3guard, llamaguard3, shieldgemma or json
scaling: {min: 2, max: 2}
profiles: [{name: cpu, engine: llamacpp}]
# in a guard policy
judge:
models: [qwen3guard-0.6b] # or model: one judge
input: block # flag or block; empty does not ask about input
output: flag
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 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:**
- `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": [...]}`.
- **What is read:**
- **Input:** the request's last user turn, after the patterns.
- **Output:** the answer, together with that user turn. A streamed answer
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
tenancy tree, and apply to everything below it:
```
llmgw ctl guard attach project:Acme/finance/payroll finance
llmgw ctl guard attach key:key-7f3a baseline
llmgw ctl guard list
llmgw ctl guard detach project:Acme/finance/payroll finance
```
Policies narrow, as limits do. A policy attached lower can tighten what
applies above it, never loosen it. Combined, they take:
- the strictest mode and failure;
- for each kind, the strictest action;
- every deny pattern and denied tool;
- only the tools every allow list allows;
- the smallest limits;
- `no_store` if any of them asks;
- every pinned prompt, widest first.
An attachment applies at once.
## What a client sees
**A refused request** is answered with 400:
```json
{"error": {"type": "invalid_request_error", "code": "content_policy_violation",
"message": "this request was refused by the gateway's policy (secret)"}}
```
It names the category, never the text, and costs nothing.
**A stopped answer** ends with `finish_reason: "content_filter"`. A stream
ends with that chunk and `[DONE]`, and the engine is cancelled. The
Responses API reports it as `incomplete` with
`incomplete_details.reason: "content_filter"`.
**Headers:** `x-llmgw-guard` (`flagged`, `redacted`, `blocked`) and
`x-llmgw-guard-policy` say what was done.
**Streams.** A streamed answer is judged a sentence at a time. Text is held
until a sentence, a line or 200 characters ends, released up to its last
whitespace — so a value still arriving is never sent in two halves — and
rewritten if needed. **What was released cannot be taken back**: a policy
that must never let a word through uses `stream: buffer_all`, which holds
the whole answer until it is judged.
## Shadow mode
`mode: shadow` judges and records — the usage row says `shadow-blocked`,
`shadow-redacted` — and changes nothing. Run a new policy in shadow first,
and read what it would have done before enforcing it.
## What is recorded
Each request's usage row has the action, policy, category and the time the
checks took. The access log has the action, stage and policy. Neither has
the text, a matched value, or the placeholder map: the map exists only in
the gateway's memory while the request is served.
## In the console
`sudo llmgw` has a **Guardrails** view: each policy with its mode, what it
does when it cannot judge, and what it checks; whether each judge model it
leans on has replicas ready — a policy that fails closed refuses everything
while its judge has none; where policies are attached, with A to attach one
and Enter on a row to detach it; and what they did in the last day (W for an
hour or a week), totals first and the newest requests after, each with the
request id `llmgw ctl request` reads. The same is at `GET /api/v1/guard` and
`GET /api/v1/guard/events?since=24h`, and on the portal's Guardrails page.
+2832 -176
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.
+228
View File
@@ -0,0 +1,228 @@
# llmgw and Kubernetes
The control plane runs in Kubernetes, from the Helm chart in
`deploy/helm/llmgw`. The GPU nodes do not: they stay ordinary machines,
joined with `llmgw node add` as on any other cluster. This page says why,
and how the two meet.
## What goes where
| Part | Where | Why |
|---|---|---|
| Control plane: the API on 8080, the agents' port 7443, the scheduler | A StatefulSet, from the chart | A Go binary with a store and three ports: what Kubernetes runs well |
| Store | A file on a PersistentVolumeClaim (SQLite), or your PostgreSQL | Either, as on a host |
| GPU and CPU nodes | Outside, as hosts | See below |
| Model mirror | Outside, on a machine with a large disk | Every node pulls tens of gigabytes from it over plain HTTP; a pod with a volume that size is a host in all but name |
### Why the nodes stay outside
The agent on a node is not a service that happens to use a GPU. It installs
the engines on the machine — the llama.cpp build for its driver, vLLM in a
Python environment — checks them against their publisher's checksums and
runs them; it chooses which GPUs each replica gets, by index and by NVLink
topology; it starts engines as processes that outlive its own restarts, so a
node upgrades while it serves; it writes the machine's firewall; and it
replaces its own binary when the cluster upgrades. All of that is root on
the host, which is exactly what a container is built to prevent.
Run as a DaemonSet it would need to be privileged, with the host's network,
process namespace and filesystem, and the NVIDIA device plugin would have to
be kept off its GPUs — or the two would hand the same card to two owners.
What is left is the host agent with a container's indirection around it, and
none of the scheduling, isolation or restart guarantees that make a pod
worth having. So the chart does not ship one, and this is the
recommendation:
- **Keep GPU machines out of Kubernetes**, and add them with `node add`.
- **If they are already Kubernetes workers**, dedicate them: taint them so
no other pod lands there, keep the NVIDIA device plugin off them (most
installs select nodes by label — leave the label off), and run the llmgw
agent on the host, where `node add` puts it. llmgw then owns the GPUs, and
Kubernetes schedules nothing that would want them.
## The image
The project publishes binaries, not an image. Build one from the repository
and push it where the cluster pulls from — at an offline site, the internal
registry:
```
docker build -f deploy/container/Dockerfile --build-arg VERSION=v0.11.1 \
-t registry.internal/llmgw:v0.11.1 .
docker push registry.internal/llmgw:v0.11.1
```
It is the static binary on a distroless base: no shell, running as user
65532, with a read-only root filesystem in the pod.
## The agent port's certificate
Agents and the control plane authenticate each other with certificates from
llmgw's own authority, and the TLS ends in the pod — so the authority's key
stays outside the cluster, on the machine you will add nodes from, and the
pod is given only its own certificate.
Decide first the address agents will dial: the `-agents` Service is a
LoadBalancer, so give it a fixed `agentService.loadBalancerIP` or a DNS name
that points at it. That address has to be in the certificate:
```
llmgw ctl cert init-ca --dir ./pki
llmgw ctl cert issue-server llmgw-ctl --host 10.0.0.200 --host llmgw-agents.example.com \
--dir ./pki --out ./pki
kubectl -n llmgw create secret generic llmgw-agent-tls \
--from-file=ca.pem=pki/ca.pem \
--from-file=server.pem=pki/server.pem \
--from-file=server-key.pem=pki/server-key.pem
```
Keep `pki/ca-key.pem` as you would on a host: whoever holds it can make any
node's identity.
## Installing
```yaml
# values.yaml
image:
repository: registry.internal/llmgw
tag: v0.11.1
agentTLS:
existingSecret: llmgw-agent-tls
agentService:
loadBalancerIP: 10.0.0.200
ingress:
enabled: true
className: nginx
hosts: [{host: llm.example.com, paths: [{path: /, pathType: Prefix}]}]
tls: [{secretName: llm-example-com, hosts: [llm.example.com]}]
config:
mirror:
api: http://models.internal:8082
# The address the router reaches nodes' engines from is a Kubernetes
# node's, once the pod's traffic leaves the cluster: the nodes' firewalls
# must let it in.
firewall_networks: ["10.0.1.0/24"]
```
```
helm install llmgw deploy/helm/llmgw -n llmgw -f values.yaml
```
`config` is `server.yaml`: any key of [reference.md](reference.md), laid
under what the chart sets itself — the listeners, where the certificates,
the admin token and the store are mounted. A changed `config` restarts the
pod.
**The store.** By default SQLite, on a 10 GiB claim. For PostgreSQL, put its
address in a Secret and point the chart at it:
```
kubectl -n llmgw create secret generic llmgw-store \
--from-literal=dsn='postgres://llmgw:…@pg.internal:5432/llmgw?sslmode=verify-full'
```
```yaml
store:
type: postgres
postgres:
existingSecret: llmgw-store
```
**One control plane.** The chart runs one replica. Two against PostgreSQL
would elect a leader and keep a standby, but a standby serves nothing and is
never ready, so a StatefulSet rollout would wait on it for ever. Kubernetes
replaces the one pod within seconds when it goes, and agents reconnect on
their own; the replicas on the nodes keep serving meanwhile.
**Probes.** Alive is `/health`. Ready is `/leader`, which on PostgreSQL waits
for the lease of the pod it replaced to run out — not `/ready`, which waits
for a model replica, and would keep a cluster with no node yet from ever
accepting one.
**Monitoring.** The metrics port, 9091, serves metrics and nothing else;
`serviceMonitor.enabled: true` makes a ServiceMonitor for the Prometheus
Operator, and `deploy/monitoring` holds the rules and dashboards.
## Using it
The admin API listens on the pod's loopback, as on a host — it can dump keys
from the heap when profiling is on. Reach it through the pod:
```
kubectl -n llmgw exec -it statefulset/llmgw -- llmgw users create you@example.com --password '…'
kubectl -n llmgw exec -it statefulset/llmgw -- llmgw users role you@example.com admin
kubectl -n llmgw exec -it statefulset/llmgw -- llmgw ctl status
```
or from your machine, through a port-forward:
```
kubectl -n llmgw port-forward statefulset/llmgw 9090:9090 &
export LLMGW_ADMIN_TOKEN=$(kubectl -n llmgw get secret llmgw-admin -o jsonpath='{.data.admin-token}' | base64 -d)
llmgw ctl status
```
The token is made by the chart and kept across upgrades
(`adminToken.existingSecret` brings your own).
## Adding nodes
From the machine holding `pki/`, with the port-forward above running and
`LLMGW_ADMIN_TOKEN` set:
```
sudo -E llmgw node add ubuntu@gpu01 \
--server https://10.0.0.200:7443 --pki ./pki --admin 127.0.0.1:9090
```
`--server` is the address in the certificate. The node is installed, checked
and started over ssh exactly as on any cluster ([node-onboarding.md](node-onboarding.md)),
and dials the control plane in the pod. Its firewall comes from
`firewall_networks` in the server configuration `node add` reads (`--config`,
`/etc/llmgw/server.yaml` by default): copy the chart's out of the cluster
first, or give `--no-firewall` and keep the engines' ports open to the
Kubernetes nodes by hand.
```
kubectl -n llmgw get configmap llmgw -o jsonpath='{.data.server\.yaml}' > server.yaml
sudo -E llmgw node add ubuntu@gpu01 --config ./server.yaml --server https://10.0.0.200:7443 \
--pki ./pki --admin 127.0.0.1:9090
```
## Offline
An offline site ([install.md](install.md), "Offline sites") works the same
way with the control plane in a pod. Import the bundle on the mirror's
machine with `llmgw bundle import`, record its engines' checksums in a
file, and give that file to the chart as a ConfigMap:
```
llmgw bundle trust llmgw-bundle.tar --sha256 3f2a… --file ./offline.sha256
kubectl -n llmgw create configmap llmgw-offline --from-file=offline.sha256
```
```yaml
offlineChecksums:
configMap: llmgw-offline
config:
offline:
mirror: http://models.internal:8081
```
A later bundle: `bundle trust` into the same file, and replace the ConfigMap
(`kubectl create configmap … --dry-run=client -o yaml | kubectl apply -f -`).
The control plane reads the file again when it changes; there is no restart.
## What has and has not been tried
The chart is checked with `helm lint` and `helm template` for SQLite,
PostgreSQL, an Ingress, a ServiceMonitor and an offline cluster, and refuses
what would not work — no image, no certificate, a PostgreSQL store without
its address, more than one replica. The image has been run the way the
chart runs it — read-only root, user 65532, the same files mounted at the
same paths, SQLite on a volume — and answered `/health`, `/leader` and
`/metrics`, with `llmgw ctl` and `llmgw users` working through `exec`.
It has **not** been installed on a Kubernetes cluster, and no node has yet
joined a control plane running in one. Until it has, a control plane on a
host is the well-trodden path.
+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.
+50 -71
View File
@@ -4,24 +4,16 @@ Written for whoever runs the cluster.
## The short version
Where one person holds both the hypervisor and the certificate authority —
which is the common case — one command does the whole thing in about
45 seconds:
On the control plane:
```
llmgw-add-node \
--name llmgw-node05 --ip 10.0.0.145/24 \
--server https://10.0.0.140:7443 \
--cores 8 --memory 32768 --disk 60 \
--llamacpp-bin /opt/llama.cpp/llama-server
sudo llmgw node add ubuntu@10.0.0.145
```
The VMID defaults to the last octet of the address, so `qm list` and
`llmgw ctl nodes` stay in the same order. `--provision-only` and
`--onboard-only` split it back into the two halves below when the two kinds
of access belong to different people.
The rest of this page is what that command does, and what it still does not.
That is all a machine that already exists needs — bare metal, or a VM you
built. [install.md](install.md#5-adding-the-node) says what it does. On
Proxmox, the machine itself can be built first by the script below, on the
hypervisor.
## The two halves
@@ -54,41 +46,31 @@ their working tree.
## 2. The identity
On the host that holds the certificate authority — this is the part that cannot
be delegated, because it issues the credential by which the control plane
believes the node is who it says it is:
On the control plane, which holds the certificate authority — this is the
part that cannot be delegated, because it issues the credential by which the
control plane believes the node is who it says it is:
```
llmgw-onboard-node \
--node llmgw-node04 \
--addr 10.0.0.144 \
--server https://10.0.0.140:7443 \
--fabric ib-b
sudo llmgw node add ilker@10.0.0.144 --fabric ib-b:mlx5_0,mlx5_1
```
It issues a client certificate whose common name is the node id, installs it
along with the CA root, trusts that root system-wide so other tooling on the
machine stops treating llmgw's certificates as strangers, installs the binary
and the unit, and starts the agent. The node appears in `llmgw ctl nodes` within
a few seconds.
It checks the machine with `llmgw doctor`, issues a client certificate whose
common name is the node id (the hostname), installs it along with the CA root,
trusts that root system-wide, installs the binary and the unit, starts the
agent, and waits for the node to join.
If it does not, the control plane's log says why. A certificate whose common
name does not match the claimed node id is refused by design — that check is
what makes a node's identity something it proves rather than something it picks.
If it does not join, the agent's log on the node says why. A certificate whose
common name does not match the claimed node id is refused by design — that
check is what makes a node's identity something it proves rather than
something it picks.
**It needs ssh from the CA host to the node.** Provisioning copies that host's
`authorized_keys` into the new machine, so a node built by the script above
already satisfies it; an older machine may need the key added.
**It needs SSH from the control plane to the node.** Provisioning copies the
hypervisor's `authorized_keys` into the new machine; with `ssh -A` to the
control plane and `sudo --preserve-env=SSH_AUTH_SOCK`, the same key that
reaches the hypervisor reaches the node.
It waits for ssh rather than failing on it, and learns the host key of a machine
it has not seen (`accept-new`, which still refuses a *changed* key — the risk
being guarded against is a substituted machine, not a new one). Both are needed
to run the halves back to back: for the first minute of its life a new machine
is still regenerating host keys and restarting sshd, so it refuses connections
it accepted a moment earlier.
It also checks that a configured engine actually runs, which is cheap here and
expensive later — see below.
`deploy/bootstrap/onboard-node.sh` is the script this replaced, for a machine
holding an authority but not a current llmgw.
## The CPU model matters, and its default is wrong
@@ -116,32 +98,23 @@ The trade is live migration: a VM pinned to the host CPU cannot move to a
machine with a different one. For a node whose job is arithmetic that is the
right way round, and `--cpu` overrides it where it is not.
## What onboarding does not do
## Engines
It gives a machine an identity and an agent. It does **not** install inference
engines or model weights, and a node missing either looks healthy in
`llmgw ctl nodes` right up until a replica is placed on it and the shard dies
with "executable file not found".
The script installs none, and does not need to: once the agent is connected,
the control plane tells it which engines to have and it installs them itself
— llama.cpp on every node, vLLM on every node with an NVIDIA GPU it can run
on, SGLang where the control plane names a version. Each is checked to run
before the node offers it, so a node never advertises an engine that cannot
start. [install.md](install.md#engines) says what vLLM needs of the node.
Onboarding does check that an engine you point it at can be executed, and says
so loudly when it cannot. That check exists because the failure is invisible
otherwise: `llama-server` on node05 was present, executable, and could not start
for want of `libgomp1`, and the node reported `ready` in `llmgw ctl nodes` the
whole time. Being told at onboarding beats finding out when a replica is placed
and the reason is three logs away.
`--llamacpp-bin`, `--vllm-bin` and `--sglang-python` are for an engine you
installed yourself. For the agent to use yours rather than install its own,
also set that engine's version to `""` under `engines` in `server.yaml`.
So the node is not finished until:
- the engines its catalog profiles name are installed (`vllm`, `sglang`,
`llama-server`), at the paths the agent configuration gives
- the weights are on disk, at the `source` each model declares
Both are deliberately outside this script. Engines are a platform decision with
versions and drivers attached, and distributing weights is its own problem —
hundreds of gigabytes that want a shared filesystem or a cache, not an scp from
a laptop. llmgw will fetch weights for a model whose `source` is a URL, one
copy per node, which covers the simple case; anything larger stays the
operator's arrangement, and llmgw is content to find the files already there.
Weights are the other thing a node needs. llmgw fetches them for a model whose
`source` is a URL, one copy per node, which covers the simple case; anything
larger — a shared filesystem, a cache — stays the operator's arrangement, and
llmgw is content to find the files already there.
The `sim` engine needs neither, which is what makes it useful for exercising
everything else.
@@ -178,18 +151,24 @@ was cut short.
## Fabrics
`--fabric` is how a node says which InfiniBand fabric it is attached to, and
there is no way around stating it. OpenSM's default subnet prefix is `fe80::` on
every fabric, so a matching prefix proves nothing and a wrong guess produces a
replica that starts, passes health checks, and then hangs on its first
all-reduce.
`--fabric` is how a node says which InfiniBand fabric it is attached to. The
name is checked against what the adapters report — every port on one fabric
sees the same subnet managers — so two independent fabrics given one name are
placed as two, and the cluster page says so; give each fabric its own name.
The agent installs `infiniband-diags`, which that check needs, if it is missing.
Before a node with a fabric joins, run `sudo llmgw doctor` on it: it checks the
subnet manager, GPUDirect RDMA, the fabric manager and IMEX on NVLink machines,
and the declared fabrics against the adapters. [fabrics.md](fabrics.md) has the
rest.
Omit it for a node with no fabric. Such a node is its own island: it can host
single-node replicas and will never be given a multi-node one.
Two adapters per fabric is the default; `--fabric ib-b:mlx5_2,mlx5_3` names them
explicitly. An adapter must never appear under two fabrics — the agent refuses
that configuration, because the consequence is the hang above.
that configuration: a replica given adapters on two fabrics starts, passes its
health checks, and hangs on its first all-reduce.
## Where the driver is
+238 -33
View File
@@ -39,16 +39,55 @@ The landing page then links to it, and the rest is:
```
llmgw users list --pending --store …
ID EMAIL NAME STATE PROJECT SIGNED UP
usr-k4mq… ayse@example.com Ayşe pending - 4m12s ago
ID EMAIL STATE ADMIN MEMBER OF SIGNED UP
usr-k4mq… ayse@example.com pending - on their own 4m12s ago
llmgw users approve usr-k4mq… --project default --by ilker --store …
llmgw users approve ayse@example.com --by ilker --store …
llmgw users approve ayse@example.com --project Acme/ml/training --store … # or straight into a project
```
That is the whole operator loop. From then on they sign in at `/login`, and
`/profile` is where they create keys, see when each was last used, and revoke the
ones they are done with. A key is shown once on the page that creates it and
never again.
their account page (`/profile`) is where they create keys, see when each was
last used, revoke the ones they are done with, and see their own use over the
last thirty days against every budget that applies to them. A key is shown once
on the page that creates it and never again.
### Connecting an editor
The account page's **Connect your editor** makes a key for 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
`codex --profile llmgw`; their default model stays as it was. Codex speaks the
Responses API, which the gateway translates at `/v1/responses`.
- **Cline** — four settings to paste into its own screen, since it keeps them
in VS Code's secret storage where no file reaches.
- **Claude Code** — not on the page yet; it speaks Anthropic's Messages API,
which the gateway translates at `/v1/messages`:
`ANTHROPIC_BASE_URL=https://<gateway> ANTHROPIC_AUTH_TOKEN=<key>
ANTHROPIC_MODEL=<model> ANTHROPIC_SMALL_FAST_MODEL=<model> claude`. It
needs a model that calls tools well.
For Continue and Codex there is a one-line command,
`curl -fsSL <gateway>/setup/<tool>?model=… | sh`. Its script is served without
signing in and holds no secret — the key comes from the environment — and it
keeps a copy of anything it replaces. Revoking the key disconnects the tool.
How well an editor's agent mode works depends on the model: it needs one that
calls tools well, which the coder models do and small general ones often do not.
**This gateway sends no email.** There is no "your account was approved" message
and no password reset, so tell people what to expect: they sign up, they tell
@@ -88,11 +127,91 @@ means anything.
If you have no structure yet, one of each is fine:
```
llmgw keys init --store … --org mynodes --team default --project default
llmgw org add org mynodes
llmgw org add team default --in mynodes
llmgw org add project default --in mynodes/default
llmgw org tree --people
```
It is idempotent: running it again renames rather than duplicating, so it can
live in whatever provisions the machine.
Everything that takes an organization, team or project — here, in `users` and
in `keys` — accepts its id, its path (`mynodes/default/default`) or its name
when the name is unique, so an id never has to be copied off a page. When there
is only one project, `--project` can be left out.
The tree is not fixed once made:
```
llmgw org rename project Acme/platform/gateway api-gateway
llmgw org move project Acme/platform/gateway --to Scania/ml
llmgw org remove team Acme/old # only when nothing is in it
llmgw users delete ayse@example.com --yes # signs out, revokes their keys
llmgw keys list --all # every key, where it spends, and whose
```
A project that ever had a key cannot be removed, because usage history refers
to it — rename it instead.
### Where people belong
A person can be a member of any number of organizations, teams and projects, or
of none:
```
llmgw users join ayse@example.com --team Acme/ml # every project in ml
llmgw users join ayse@example.com --project Scania/research/vision
llmgw users leave ayse@example.com --project Acme/ml/training
```
A member of a team or organization may use every project in it. Leaving revokes
the keys the person made in projects they can no longer reach; a project they
still reach through another membership keeps them.
Someone who is a member of nothing still uses the gateway, on their own:
personal keys, and the chat page, within their personal limits. When they make
a key on `/profile` they choose what it spends against — one of their projects,
or "Personal". An operator can do the same:
```
llmgw keys create laptop --for ayse@example.com # personal
llmgw keys create ci --for ayse@example.com --project Acme/ml/training
```
Personal use can be limited by default, or turned off so people use the gateway
only through their projects:
```yaml
auth:
personal:
rpm: 20
tpm: 50000
budget: 10 # whole units
period: month
# disabled: true
```
A personal key has no project, so it keeps no conversations or agent notes;
those are kept per project.
### Organization managers
A member of an organization can be made its manager:
```
llmgw users join boss@acme.com --org Acme --role manager
```
or with the star on their organization membership on `/admin/users`. A manager
gets an **Organization** link and a page at `/org`: usage over time by team,
project or person, the teams and projects with their limits and spend, and every
member. Only what was spent in that organization's projects is on it; what its
people do elsewhere is not. The page is read-only — a manager is not an
administrator of the cluster, and limits and memberships stay with those who
are. An administrator 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;
it says so and stops.
Split it when you want separate limits or separate reporting, not before.
@@ -156,18 +275,34 @@ Allowlists compose by intersection: a key cannot add a model its team withheld.
A model's canonical name and any of its aliases name the same thing, so either
form works.
Budgets are counted in micro-units, and what a unit means is yours to decide.
Self-hosted models have no per-token price, so it can be GPU-seconds, a notional
rate, or 1 000 000 everywhere — which turns a budget into a plain token quota.
Budgets are given in whole units, as the Limits tab shows them, and what a unit
means is yours to decide. Self-hosted models have no per-token price, so it can
be GPU-seconds, a notional rate, or one unit per thousand tokens everywhere —
a price of 1 000 000 in `auth.pricing`, which is in micro-units — and a budget
becomes a plain quota of thousands of tokens.
Models are priced in the server's `auth.pricing` section; a model priced at zero
is exempt from budget checks, which is what lets a key with nothing left still be
served a cheap fallback.
```
llmgw keys limits --store … --scope project --id default \
--budget 50000000 --period month # 50 units a month
llmgw keys limits --store … --scope project --id Acme/platform/gateway \
--budget 50 --period month # 50 units a month
```
Two more kinds of limit follow people rather than the tree:
```
# A person, everywhere: every key they hold, every project, the chat page
llmgw keys limits --scope user --id ayse@example.com --budget 25
# Each member of a project separately — a fair share, not one shared pool
llmgw keys limits --scope project --id Acme/ml/training --per-member --rpm 20
```
A request is held to all of them at once: the key's own, its project's, team's
and organization's, each per-member limit on those for its owner, and the
owner's own. The strictest wins, and each is counted in its own pool.
## The landing page
Someone who reaches the gateway without a key sees the landing page. Tell it who
@@ -184,6 +319,28 @@ shown as text. Other schemes are never linked — configuration is not always
written by whoever runs the gateway, and a `javascript:` href on a page served to
every visitor is a short path to a stored XSS.
## The docs portal
The gateway also serves its manual at `/docs`: these pages, rendered, with
[clients.md](clients.md) — how to use the gateway from the official OpenAI
and Anthropic SDKs — first on its index. The landing page and the account
page link to it, so a user's questions about streaming, tools or the Anthropic
SDK have an answer that is not you.
It is public, like the landing page. The client guide is for the person who
has no account yet, and the operator documents describe llmgw in general —
no address, model, node or key of this installation — which is what the
project's repository publishes anyway. A site that would rather not say even
that turns sign-in on:
```yaml
landing:
docs_sign_in: true
```
A visitor is then sent to sign in first. On a gateway with API keys and no
accounts, the portal asks for a key instead, like the API.
## What the user sees
They open the link, press the button, and get the key once. The page shows them
@@ -205,8 +362,15 @@ llmgw keys list --store … --project default
minute. It is the column that tells you whether a key is still in use, which is
what makes rotation safe:
**To rotate**, issue the new key, let both work while the user switches, watch
`LAST USED` on the old one stop moving, then revoke it.
**To rotate**, `llmgw keys rotate <key-id> --overlap 48h` issues a new key with
the same project, owner and limits and lets the old one work for the overlap,
then stop. Watch `LAST USED` on the old one stop moving before it runs out.
**To cap how long keys live**, `llmgw keys ttl project:Acme/ml/train 720h` (or
`team:`, `org:`; `off` removes it), and `auth.max_key_ttl` in server.yaml for
every key. A key is made within the shortest limit that applies, and one asked
for without an expiry gets that limit. `keys list --all` shows each key's kind
— service (a project's), member, personal — and when it expires.
```
llmgw keys revoke --store … key-mpmip77twpt7qaxc
@@ -225,16 +389,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
@@ -286,9 +472,23 @@ page exists.
## Managing accounts from the browser
`/admin/users` lists who is waiting and who is active. Approving (into a project)
and suspending are there, because that is the work an operator does constantly
and it should not need a terminal.
`/admin/users` is the tree: organizations, teams and projects, each project
opening onto its members and its keys. From there an admin approves people
(into a project or on their own), adds them to and takes them out of
organizations, teams and projects, suspends and removes them, revokes keys, and
adds, renames, moves and removes organizations, teams and projects. A
searchable list of every account is at the bottom. Choosing any organization,
team, project or person opens a panel beside the tree with its limits, its
capacity quota and its use over the last week, and its limits can be changed
there.
The same page has two more tabs, each at the address it had as a page of its
own: **Limits** (`/admin/quotas`) is every limit in the tree at once — per
branch, per member of any part of it, and per person — with the capacity quotas;
**Usage** (`/admin/usage`) is the cluster's use over time by organization, team,
project or person, and the GPU time each owner held. The page works as plain
forms; with scripts on, a change updates it in place and the projects you had
open stay open.
Three things are deliberately not there:
@@ -299,21 +499,26 @@ Three things are deliberately not there:
| `llmgw keys create` | Issuing a credential on another person's behalf |
Each of those turns a stolen admin cookie into more than it was, and each is
rare enough that a shell is no hardship. Cordon, drain and everything else that
touches the cluster are also CLI-only for the same reason: the dashboard shows
the cluster, it does not steer it.
rare enough that a shell is no hardship. Cordon and drain are CLI-only for the
same reason; what the pages do change on the cluster — serving models through
the mirror, a node's engines — needs the `operator` or `admin` staff role.
Every administrative action is logged with who did it, including the ones that
were refused.
Every administrative change is recorded in the audit log — who, when, through
what, the fields given with secrets taken out, and how it ended, refusals
included: `llmgw ctl audit`, or **Audit** in the console.
## What this does not do
- **No email.** No approval notice, no self-service password reset, no address
verification. The loop is manual on purpose at this size; if it stops being
the right size, that is the thing to automate.
- **No SSO.** Accounts are local, with local passwords.
- **No roles.** There is one flag, `is_admin`, and it grants looking at the
dashboard. Everything else an administrator does is the CLI, and having a
shell here is what makes someone one.
- **Single sign-on is optional.** With `auth.oidc` people sign in through
your identity provider, and SCIM 2.0 (`auth.scim`) creates, updates and
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`).
- **Rate limits are per gateway instance.** With two instances each counts its own,
so the effective limit doubles. v1 is single-instance by design.
+248
View File
@@ -0,0 +1,248 @@
# Quick install
From empty servers to a model answering requests, in six steps. This page
has only the commands and what to check after each; [install.md](install.md)
explains every choice.
## What you need
| | |
|---|---|
| **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`). |
| **Network** | nodes reach the control plane on 7443 and 8081; users reach it on 8080. The machines need internet access to download engines and models. |
You do not install llama.cpp, vLLM, Python or CUDA. The nodes do that
themselves once they join.
## 1. Put llmgw on the control plane
```
VERSION=$(curl -fsSL https://gitea.mynodes.xyz/api/v1/repos/ilker/llmgw/releases/latest \
| grep -o '"tag_name":"[^"]*"' | cut -d'"' -f4)
U=https://gitea.mynodes.xyz/ilker/llmgw/releases/download/$VERSION
curl -fsSLO $U/llmgw-$VERSION-linux-amd64 # -linux-arm64 on ARM
curl -fsSL $U/checksums.txt | sha256sum -c --ignore-missing
sudo install -m0755 llmgw-$VERSION-linux-amd64 /usr/local/bin/llmgw
llmgw --version
```
`sha256sum` must print `OK`. If it does not, the download is damaged; do not
install it.
## 2. Set up the control plane
```
sudo llmgw
```
With nothing installed, `llmgw` opens the setup screens. They ask whether
this is **one machine or three** (high availability), and for three, the
other two: address, user, and an ssh key, the ssh agent or a password. They
check every machine before installing anything — system, disk, clock, ports,
internet, whether the machines reach one another — show the plan, and
install:
- the service, its certificates and its configuration, and a firewall for
its internal ports;
- with three machines, the store replicated across them (PostgreSQL under
Patroni);
- 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 (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).
Run again later, `sudo llmgw` is the console: the cluster's state and
everything you can do to it ([console.md](console.md)). Every step below is
also there, under **Do**.
**In a script**, the same for one machine:
```
sudo llmgw server init --admin you@example.com
```
If nodes will reach this machine by a name or address it does not know itself
(a DNS name, a NAT address), add `--host` for each:
`--host llm.example.com`. It is safe to run again: it keeps what exists and
finishes what is missing.
**The mirror on its own machine**, when it wants a larger disk than the
control plane has: first on that machine, with llmgw installed as in step 1,
```
sudo llmgw mirror init --control-plane <control plane address>
```
then on the control plane `sudo llmgw server init --mirror http://<mirror
address>:8082`. The mirror serves model files to every node, and takes
orders only from the control plane. Untick "This machine as a node" in the
form (or add `--no-node`) to keep models off the control plane; the starter
model then starts on the first node you add.
**Check:** open `http://<control-plane>:8080/chat`, sign in with the printed
password, and ask something. The small model answers. Change the password on
the account page.
## 3. Add the GPU nodes
In the console, **Do → Add a node**. Or on the control plane:
```
sudo llmgw node add ubuntu@10.0.0.21
```
Several at once: `sudo llmgw node add ubuntu@10.0.0.21 ubuntu@10.0.0.22 ubuntu@10.0.0.23`.
Replace `ubuntu` with the user you log in to the node as, and `10.0.0.21` with
its address. That user needs `sudo` without a password on the node.
It logs in over SSH and, while you watch:
1. installs llmgw on the node, the release for its processor, checksum checked;
2. runs `llmgw doctor` there, and stops if the node has a problem. Each problem
comes with the command that fixes it. Fix it and run the same command again;
3. gives the node its certificate and configuration, and starts its agent;
4. waits for the node to join and install llama.cpp.
The node's name in the cluster is its hostname. vLLM installs by itself after
that, in a few more minutes on a GPU node (about 8 GB). The cluster page
(`/admin`) shows its progress, or why the node cannot run it.
**InfiniBand.** When the node's adapters are up, `node add` shows them grouped
by the fabric they are on and asks for each fabric's name. Give every node on
the same fabric the same name. In a script, give it instead:
```
sudo llmgw node add ubuntu@10.0.0.21 --fabric ib-a:mlx5_0,mlx5_1,mlx5_2,mlx5_3
```
Once two nodes on a fabric are in, check they reach each other at the link's
rate before placing anything across them:
```
llmgw ctl fabric-test gpu01 gpu02
```
**If it cannot log in.** `sudo` runs SSH as root, with root's keys. To use your
own keys instead, log in to the control plane with `ssh -A` and keep your
agent through sudo:
```
sudo --preserve-env=SSH_AUTH_SOCK llmgw node add ubuntu@10.0.0.21
```
**Check:** `llmgw ctl nodes` lists the node. Running `node add` again on the
same node is safe: it renews its certificate and configuration, and keeps what
runs there.
A machine retired for good is removed with `llmgw node remove <name>` once its
agent is stopped.
## 4. Add a model
Open `/admin/mirror` and choose **Explore models**. It lists what is popular on
Hugging Face, showing only what your nodes can run: 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
**Fetch**. It is downloaded into the mirror once, for every node.
3. When the download finishes, click **serve** on its row in the table above.
4. In the form, choose how it runs: **always on**, or **on demand**, which
loads it on the first request and stops it after 15 minutes unused. Click
**Add to the catalog**.
The model is placed on a node that fits it, and its status on `/admin` turns
`ready`.
**Check:** the model appears in the list on `/chat`. Ask it something.
## 5. Give people access
**A key for a program:** each person makes their own on the account page
(`/profile`). The key is shown once.
```
curl http://<control-plane>:8080/v1/chat/completions \
-H "Authorization: Bearer $LLMGW_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "<model-name>", "messages": [{"role": "user", "content": "hello"}]}'
```
Any OpenAI client works. Only the base URL changes:
`http://<control-plane>:8080/v1`.
**An editor:** the account page connects Continue, Codex or Cline. Pick the
tool and a model, and it gives a one-line command that writes the
configuration.
**Other people's accounts:** they sign up at `/signup`, and you approve them on
the people page (`/admin/users`).
## 6. Put it behind HTTPS
Port 8080 is plain HTTP. Before users outside a trusted network use it, put a
reverse proxy in front with a real certificate. With Caddy, which gets the
certificate by itself:
```
sudo apt-get install -y caddy # Debian and Ubuntu
curl -fsSL $U/Caddyfile | sed 's/llm.example.com/YOUR.DOMAIN/' | sudo tee /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Keep 7443 (nodes) and 8081 (mirror) on the internal network. They never go
through the proxy. There is an nginx configuration too:
[deploy/proxy/](../deploy/proxy/).
## When something does not work
| What you see | What to do |
|---|---|
| `node add` stops at `join` | `sudo journalctl -u llmgw-agent -n 50` on the node. Usually it cannot reach port 7443 on the control plane. |
| `node add` says the address is not in the certificate | Run `sudo llmgw server init --host <that address>`, then `node add` again. |
| A node says it cannot run vLLM | The reason is on `/admin`: the driver is older than 580, the GPU is older than Turing, or there is less than 25 GB free. |
| A model shows `not placed` | `llmgw ctl explain-placement <model>` names each node and why it was not used. |
| A reply stops after 60 seconds behind nginx | Use the nginx configuration from the release. Its timeouts are set for slow models. |
| Anything else about a node | `sudo llmgw doctor` on it. `--json` writes everything it read, to send to whoever helps you. |
## Next
- [install.md](install.md): the same steps with every option explained, and
running the cluster after: monitoring, notifications, single sign-on,
backups, upgrades.
- [console.md](console.md): everything the console does.
- [fabrics.md](fabrics.md): InfiniBand and NVLink.
- [onboarding.md](onboarding.md): accounts, projects, budgets and keys.
+3710 -801
View File
File diff suppressed because it is too large. Load diff
+57 -6
View File
@@ -1,49 +1,100 @@
module gitea.mynodes.xyz/ilker/llmgw
go 1.26.3
go 1.26.6
require (
connectrpc.com/connect v1.21.0
filippo.io/age v1.3.2
github.com/NVIDIA/go-nvml v0.13.4-0
github.com/cespare/xxhash/v2 v2.3.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/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
github.com/prometheus/common v0.71.0
github.com/spf13/cobra v1.10.2
github.com/vmihailenco/msgpack/v5 v5.4.1
github.com/yuin/goldmark v1.8.6
go.opentelemetry.io/otel v1.46.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0
go.opentelemetry.io/otel/sdk v1.46.0
go.opentelemetry.io/otel/trace v1.46.0
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/crypto v0.57.0
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
)
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
github.com/charmbracelet/colorprofile v0.4.1 // indirect
github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/clipperhouse/displaywidth v0.9.0 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-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
github.com/go-zeromq/goczmq/v4 v4.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/kylelemons/godebug v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/prometheus/procfs v0.21.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.9 // indirect
go.yaml.in/yaml/v3 v3.0.5 // indirect
golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.42.0 // indirect
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/grpc v1.83.1 // indirect
modernc.org/libc v1.75.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
+130 -2
View File
@@ -1,25 +1,84 @@
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d h1:Blprhc2SbChNZtWcU+BLTM4YdoqYAS9V7cJgOwJKyAs=
c2sp.org/CCTV/age v0.0.0-20260829155415-4448f2097b2d/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
connectrpc.com/connect v1.21.0 h1:LhqSJt7jHf5NJBo9Jq/t/9FjcYAideif0mg+qe2jCUs=
connectrpc.com/connect v1.21.0/go.mod h1:A2ygJrukXwWy32vkCAAHNVguZrqZ+jeZ9rGRnGR4dN4=
filippo.io/age v1.3.2 h1:r6RSZLFSMm6rzKepZ7ZAYkKCu14f3/Me8c7uKYh7C8c=
filippo.io/age v1.3.2/go.mod h1:TH/Yr2sSRhCKbaH4XPxpUV0Us8Gv6txYUpiZQWz8Evk=
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=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM=
github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-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=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-zeromq/goczmq/v4 v4.2.2 h1:HAJN+i+3NW55ijMJJhk7oWxHKXgAuSBkoFfvr8bYj4U=
github.com/go-zeromq/goczmq/v4 v4.2.2/go.mod h1:Sm/lxrfxP/Oxqs0tnHD6WAhwkWrx+S+1MRrKzcxoaYE=
github.com/go-zeromq/zmq4 v0.17.0 h1:r12/XdqPeRbuaF4C3QZJeWCt7a5vpJbslDH1rTXF+Kc=
github.com/go-zeromq/zmq4 v0.17.0/go.mod h1:EQxjJD92qKnrsVMzAnx62giD6uJIPi1dMGZ781iCDtY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo=
github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
github.com/hashicorp/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=
@@ -32,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=
@@ -46,12 +117,26 @@ 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=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
@@ -67,6 +152,8 @@ github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
@@ -77,6 +164,32 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/yuin/goldmark v1.8.6 h1:d0VcaP1sx9GkFVkoW+KtggpGi2KZ965i14b0+bDQST4=
github.com/yuin/goldmark v1.8.6/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM=
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
@@ -86,18 +199,33 @@ go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c=
golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o=
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
golang.org/x/oauth2 v0.37.0 h1:JUlcxA8oAtauLfiH8FX2/FkAWHAdi0QtGCGc+hofE98=
golang.org/x/oauth2 v0.37.0/go.mod h1:IxwZNxUULJmpBFf9K/9NTMSIfZZuvuTy1gGxhigP/58=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI=
golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI=
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+99 -8
View File
@@ -99,17 +99,21 @@ func TestApprovalIsWhatMakesAnAccountWork(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if got.ProjectID != "proj" {
t.Errorf("the approved account landed in project %q", got.ProjectID)
if ok, err := st.CanUseProject(ctx, got.ID, "proj"); err != nil || !ok {
t.Errorf("the approved account is not a member of the project it was approved into: %v", err)
}
}
func TestApprovalNeedsAProject(t *testing.T) {
// An approved account with nowhere to put its keys passes every check and
// then fails at the first key it tries to make.
_, st := newStoreAndUser(t)
if err := st.ApproveUser(context.Background(), "usr-1", "", "ilker", time.Now()); err == nil {
t.Error("an account was approved into no project")
func TestApprovalNeedNotNameAProject(t *testing.T) {
// A person need not belong to anything to use the gateway: approved into
// nothing, they work on their own within their personal limits.
svc, st := newStoreAndUser(t)
ctx := context.Background()
if err := st.ApproveUser(ctx, "usr-1", "", "ilker", time.Now()); err != nil {
t.Fatalf("approving with no project: %v", err)
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword); err != nil {
t.Errorf("an account approved into nothing cannot sign in: %v", err)
}
}
@@ -394,3 +398,90 @@ func TestGuessingTheCurrentPasswordIsBounded(t *testing.T) {
t.Errorf("guessing was unbounded: %v", err)
}
}
func TestSomebodyGuessingElsewhereCannotLockTheOwnerOut(t *testing.T) {
svc, st := newStoreAndUser(t)
ctx := context.Background()
must(t, st.ApproveUser(ctx, "usr-1", "proj", "ilker", time.Now()))
cfg := DefaultConfig()
cfg.MaxAttempts = 3
svc = New(st, cfg, slog.New(slog.DiscardHandler))
attacker, owner := WithClient(ctx, "203.0.113.9"), WithClient(ctx, "198.51.100.7")
for range 5 {
_, _, _ = svc.SignIn(attacker, "ayse@example.com", "nope")
}
if _, _, err := svc.SignIn(attacker, "ayse@example.com", "nope"); !errors.Is(err, ErrTooManyTries) {
t.Errorf("the guessing client was not held back: %v", err)
}
if _, _, err := svc.SignIn(owner, "ayse@example.com", goodPassword); err != nil {
t.Errorf("the owner, elsewhere, was locked out: %v", err)
}
// Spread over many clients, guessing is still bounded per address.
held := false
for i := range 40 {
c := WithClient(ctx, "192.0.2."+strings.Repeat("1", 1+i%3)+string(rune('0'+i%10)))
if _, _, err := svc.SignIn(c, "ayse@example.com", "nope"); errors.Is(err, ErrTooManyTries) {
held = true
}
}
if !held {
t.Error("guesses from many clients were not bounded")
}
}
func TestAHashThatCannotGetATurnSaysTheServerIsBusy(t *testing.T) {
was := argonWait
argonWait = 10 * time.Millisecond
t.Cleanup(func() { argonWait = was })
// Every turn taken.
n := cap(argonSlots)
for range n {
argonSlots <- struct{}{}
}
t.Cleanup(func() {
for range n {
<-argonSlots
}
})
if _, err := HashPassword(goodPassword); !errors.Is(err, ErrBusy) {
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)
}
}
+34 -2
View File
@@ -15,7 +15,9 @@ import (
"encoding/base64"
"errors"
"fmt"
"runtime"
"strings"
"time"
"golang.org/x/crypto/argon2"
)
@@ -56,7 +58,10 @@ func HashPassword(password string) (string, error) {
if _, err := rand.Read(salt); err != nil {
return "", err
}
sum := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
sum, err := idKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
if err != nil {
return "", err
}
b64 := base64.RawStdEncoding.EncodeToString
return fmt.Sprintf("$argon2id$v=%d$m=%d,t=%d,p=%d$%s$%s",
@@ -93,7 +98,10 @@ func VerifyPassword(password, encoded string) (bool, error) {
return false, ErrBadHash
}
got := argon2.IDKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
got, err := idKey([]byte(password), salt, time, memory, threads, uint32(len(want)))
if err != nil {
return false, err
}
return subtle.ConstantTimeCompare(got, want) == 1, nil
}
@@ -110,3 +118,27 @@ func init() {
}
dummyHash = h
}
// argonSlots bounds how many hashes run at once. Each takes 64 MiB, and the
// login and signup forms are on the client-facing port: without a bound, a
// few hundred parallel attempts at random addresses asked for gigabytes at
// once, and the per-address throttle does not see them — each address is new.
// Beyond the bound, attempts wait their turn instead.
var argonSlots = make(chan struct{}, max(2, runtime.NumCPU()/2))
// ErrBusy is a hash that waited its turn too long: the server is answering
// more sign-ins than it can hash, and says so rather than hold the request.
var ErrBusy = errors.New("too many sign-ins at once; try again in a moment")
// argonWait bounds the wait for a turn; a variable so a test can shorten it.
var argonWait = 10 * time.Second
func idKey(password, salt []byte, t, memory uint32, threads uint8, keyLen uint32) ([]byte, error) {
select {
case argonSlots <- struct{}{}:
case <-time.After(argonWait):
return nil, ErrBusy
}
defer func() { <-argonSlots }()
return argon2.IDKey(password, salt, t, memory, threads, keyLen), nil
}
+145 -5
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,12 +155,30 @@ 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)
if !s.allowAttempt(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
// address alone, more loosely, so guesses spread over many clients are
// still bounded.
byClient := email
if c := clientFrom(ctx); c != "" {
byClient = email + "|" + c
}
if !s.allowAttempt(byClient, s.cfg.MaxAttempts) || !s.allowAttempt("all|"+email, 10*s.cfg.MaxAttempts) {
return "", "", ErrTooManyTries
}
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.
@@ -154,11 +190,15 @@ func (s *Service) SignIn(ctx context.Context, email, password string) (token, cs
}
ok, err := VerifyPassword(password, u.PasswordHash)
if errors.Is(err, ErrBusy) {
// Not an answer about the password: said as what it is.
return "", "", err
}
if err != nil || !ok {
return "", "", ErrBadCredentials
}
// The password was right, so this was not an attack.
s.clearAttempts(email)
s.clearAttempts(byClient)
// State is checked after the password, on purpose. Checking first would
// tell anyone who guesses an address whether it has a pending account.
@@ -168,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
}
@@ -224,20 +268,53 @@ func (s *Service) CheckCSRF(sess store.Session, submitted string) bool {
// Per address rather than per IP: an attacker has many addresses to come from
// and only one to guess against, and limiting by IP would lock out everyone
// behind one office NAT the moment somebody fat-fingers their password.
func (s *Service) allowAttempt(email string) bool {
func (s *Service) allowAttempt(email string, limit int) bool {
now := s.now()
s.mu.Lock()
defer s.mu.Unlock()
w, ok := s.attempts[email]
if !ok && len(s.attempts) >= maxTrackedAddresses {
// Full of addresses nobody has signed in as for a while: forget the
// ones whose window has passed. Only expiring, never evicting early —
// dropping a live window is how an attacker spraying random addresses
// would buy unlimited guesses at the one they care about.
for k, v := range s.attempts {
if now.Sub(v.since) > s.cfg.AttemptWindow {
delete(s.attempts, k)
}
}
if len(s.attempts) >= maxTrackedAddresses {
// Still full, so somebody is spraying addresses faster than they
// expire. New ones wait; those already tracked are unaffected.
return false
}
}
if !ok || now.Sub(w.since) > s.cfg.AttemptWindow {
s.attempts[email] = &attemptWindow{count: 1, since: now}
return true
}
w.count++
return w.count <= s.cfg.MaxAttempts
return w.count <= limit
}
type clientKey struct{}
// WithClient says which client a request comes from — its address — for the
// sign-in throttle.
func WithClient(ctx context.Context, client string) context.Context {
return context.WithValue(ctx, clientKey{}, client)
}
func clientFrom(ctx context.Context) string {
c, _ := ctx.Value(clientKey{}).(string)
return c
}
// maxTrackedAddresses bounds the throttle's memory. Unbounded, it grew by one
// entry per address tried and shrank only on a successful sign-in.
const maxTrackedAddresses = 50000
func (s *Service) clearAttempts(email string) {
s.mu.Lock()
delete(s.attempts, email)
@@ -287,7 +364,7 @@ type PasswordChanger interface {
func (s *Service) ChangePassword(ctx context.Context, pw PasswordChanger,
u store.User, current, next string,
) error {
if !s.allowAttempt("pw:" + u.ID) {
if !s.allowAttempt("pw:"+u.ID, s.cfg.MaxAttempts) {
return ErrTooManyTries
}
ok, err := VerifyPassword(current, u.PasswordHash)
@@ -328,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
}
+33
View File
@@ -0,0 +1,33 @@
package account
import (
"fmt"
"testing"
"time"
)
func TestSprayingAddressesCannotGrowTheThrottleOrEscapeIt(t *testing.T) {
now := time.Unix(0, 0)
s := &Service{cfg: DefaultConfig(), attempts: map[string]*attemptWindow{}, now: func() time.Time { return now }}
// The target is being guessed at.
for i := 0; i < s.cfg.MaxAttempts; i++ {
s.allowAttempt("victim@example.com", s.cfg.MaxAttempts)
}
// Then the table is flooded with made-up addresses.
for i := 0; i < maxTrackedAddresses+100; i++ {
s.allowAttempt(fmt.Sprintf("x%d@example.com", i), s.cfg.MaxAttempts)
}
if n := len(s.attempts); n > maxTrackedAddresses {
t.Errorf("the table grew to %d", n)
}
if s.allowAttempt("victim@example.com", s.cfg.MaxAttempts) {
t.Error("flooding the table reset the target's throttle")
}
// Once the window passes, everything expires and sign-ins work again.
now = now.Add(s.cfg.AttemptWindow + time.Second)
if !s.allowAttempt("new@example.com", s.cfg.MaxAttempts) {
t.Error("a new address was refused after the flood expired")
}
}
+115 -9
View File
@@ -5,9 +5,12 @@ import (
"errors"
"fmt"
"log/slog"
"strings"
"sync/atomic"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
@@ -44,8 +47,13 @@ type Accountant struct {
tokens *Limiter
recorder *Recorder
log *slog.Logger
// notify is told when a budget runs out; nil tells no one.
notify atomic.Pointer[notify.Notifier]
}
// SetNotifier gives the accountant somewhere to say a budget is spent.
func (a *Accountant) SetNotifier(n *notify.Notifier) { a.notify.Store(n) }
// New builds an accountant.
func New(pricing *Pricing, recorder *Recorder, log *slog.Logger) *Accountant {
if log == nil {
@@ -70,32 +78,73 @@ 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
// nothing — which is a budget that resets on every restart, for exactly the
// scopes where it matters most.
spend := make(map[string]int64, len(rows)*4)
// And every pool a person is in: their own, and each per-member limit of
// the project, team and organization they spent it in.
spend := make(map[string]int64, len(rows)*8)
for _, r := range rows {
for _, o := range []store.LimitOwner{
owners := []store.LimitOwner{
{Scope: store.ScopeKey, ID: r.KeyID},
{Scope: store.ScopeProject, ID: r.ProjectID},
{Scope: store.ScopeTeam, ID: r.TeamID},
{Scope: store.ScopeOrg, ID: r.OrgID},
} {
{Scope: store.ScopeUser, ID: r.UserID},
}
if r.UserID != "" && r.ProjectID != "" {
owners = append(owners,
store.MemberOwner(store.ScopeProjectMember, r.ProjectID, r.UserID),
store.MemberOwner(store.ScopeTeamMember, r.TeamID, r.UserID),
store.MemberOwner(store.ScopeOrgMember, r.OrgID, r.UserID))
}
for _, o := range owners {
if o.ID == "" || strings.HasPrefix(o.ID, "/") {
continue
}
if k := o.CounterKey(); k != "" {
spend[k] += r.Micros
}
}
}
a.budgets.Restore(period, spend)
a.log.Info("restored budgets from the store",
"keys", len(rows), "counters", len(spend), "since", start)
return nil
return spend, len(rows), nil
}
// Reservation is what Check returns: the estimate it charged, so Record can
@@ -168,6 +217,10 @@ func (a *Accountant) Check(p auth.Principal, model string, estimatedTokens int64
if !price.Zero() {
for _, l := range chain {
if l.Budget != nil && a.budgets.Exhausted(l.Counter, l.Period, *l.Budget) {
a.notify.Load().Notify(notify.BudgetSpent, l.Counter,
fmt.Sprintf("the %s budget of %s is spent: requests to priced models are refused until it renews",
or(string(l.Period), "total"), l.Counter),
map[string]any{"counter": l.Counter, "period": string(l.Period), "budget": *l.Budget})
return res, &LimitError{Err: ErrBudgetSpent}
}
}
@@ -292,7 +345,13 @@ func (a *Accountant) Record(res Reservation, u store.Usage) {
a.tokens.Charge(c, actual-res.EstimatedTokens)
}
u.CostMicros = res.Price.Cost(u.PromptTokens, u.CompletionTokens)
// Priced by the model that answered: a fallback served by another model
// costs what that one costs, not what the one asked for would have.
price := res.Price
if u.ServedModel != "" && u.ServedModel != u.Model && a.pricing != nil {
price = a.pricing.For(u.ServedModel)
}
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)
}
@@ -302,6 +361,17 @@ func (a *Accountant) Record(res Reservation, u store.Usage) {
}
}
// RecordRefused queues the row of a request a limit refused: nothing is
// charged — it used nothing — but it is counted, so a team running into its
// limits shows as one rather than as a team that went quiet.
func (a *Accountant) RecordRefused(u store.Usage) {
if u.KeyID == "" || a.recorder == nil {
return
}
u.PromptTokens, u.CompletionTokens, u.CostMicros = 0, 0, 0
a.recorder.Record(u)
}
// Status returns what a key has used, for headers and `ctl`.
func (a *Accountant) Status(p auth.Principal) (spent int64, rpm, tpm float64) {
if p.KeyID == "" {
@@ -369,3 +439,39 @@ func keyCounter(keyID string) string {
// they are asking is whether the budgets they are about to display mean
// anything, and the accountant is what would enforce them.
func (a *Accountant) AnythingPriced() bool { return a.pricing.AnythingPriced() }
// Headroom is the tightest of a principal's request and token limits: how
// much is left of each, and when the minute it is counted over rolls on.
type Headroom struct {
RequestLimit, RequestsLeft int
TokenLimit, TokensLeft int
Reset time.Duration
}
// Headroom reports what a principal has left, after whatever Check just
// charged. Of several levels the one with least left is the one that will
// refuse first, so that is the one reported. A limit of 0 means none is set.
func (a *Accountant) Headroom(p auth.Principal) Headroom {
var h Headroom
firstReq, firstTok := true, true
for _, l := range levels(p.Limits, p.KeyID) {
if l.RPM != nil && *l.RPM > 0 {
left := max(0, *l.RPM-int(a.requests.Rate(l.Counter)))
if firstReq || left < h.RequestsLeft {
h.RequestLimit, h.RequestsLeft, firstReq = *l.RPM, left, false
}
}
if l.TPM != nil && *l.TPM > 0 {
left := max(0, *l.TPM-int(a.tokens.Rate(l.Counter)))
if firstTok || left < h.TokensLeft {
h.TokenLimit, h.TokensLeft, firstTok = *l.TPM, left, false
}
}
}
// The windows slide, so room comes back continuously; the next minute
// boundary is when a whole window's worth has moved on, and is the figure
// a client pacing itself can act on.
now := time.Now()
h.Reset = now.Truncate(windowLen).Add(windowLen).Sub(now)
return h
}
+120 -3
View File
@@ -5,11 +5,14 @@ import (
"errors"
"log/slog"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify/notifytest"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
@@ -36,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.
@@ -186,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)
@@ -630,3 +676,74 @@ func TestAnEmptyPriceTableMeansNoBudgetCanEverBind(t *testing.T) {
t.Error("no price table at all is not priced")
}
}
func TestHeadroomIsTheTightestLimitAfterThisRequest(t *testing.T) {
// A project allowing 100 a minute under a team allowing 3: the team is
// what will refuse, so the team is what a client is told about.
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
p := principal("k1", func(e *store.Effective) {
e.Chain = []store.Limits{
{Scope: store.ScopeProject, ScopeID: "proj", RPM: intp(100), TPM: intp(50000)},
{Scope: store.ScopeTeam, ScopeID: "team", RPM: intp(3)},
}
})
if _, err := a.Check(p, "m", 10); err != nil {
t.Fatal(err)
}
h := a.Headroom(p)
if h.RequestLimit != 3 || h.RequestsLeft != 2 {
t.Errorf("requests %d left of %d; expected 2 of the team's 3", h.RequestsLeft, h.RequestLimit)
}
if h.TokenLimit != 50000 || h.TokensLeft != 50000-10 {
t.Errorf("tokens %d left of %d", h.TokensLeft, h.TokenLimit)
}
if h.Reset <= 0 || h.Reset > time.Minute {
t.Errorf("reset %v", h.Reset)
}
if none := a.Headroom(principal("k2", nil)); none.RequestLimit != 0 || none.TokenLimit != 0 {
t.Errorf("a key with no limits advertised some: %+v", none)
}
}
func TestAFallbackIsChargedWhatTheModelThatAnsweredCosts(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
p := inTree("fb-k", teamBudget("fb", 1_000_000))
res, err := a.Check(p, "paid", 400)
if err != nil {
t.Fatal(err)
}
// Asked for "paid", answered by "free": nothing is owed.
a.Record(res, store.Usage{KeyID: "fb-k", Model: "paid", ServedModel: "free", PromptTokens: 200, CompletionTokens: 200})
if spent, _, _ := a.Status(p); spent != 0 {
t.Errorf("charged %d for a request the free model answered", spent)
}
res, _ = a.Check(p, "paid", 400)
a.Record(res, store.Usage{KeyID: "fb-k", Model: "paid", ServedModel: "paid", PromptTokens: 200, CompletionTokens: 200})
if spent, _, _ := a.Status(p); spent == 0 {
t.Error("the paid model answering cost nothing")
}
}
func TestASpentBudgetTellsSomeoneOnce(t *testing.T) {
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
n, sent := notifytest.Catch(t)
a.SetNotifier(n)
p := principal("k1", func(e *store.Effective) {
e.Budget = i64p(100)
e.BudgetPeriod = store.PeriodMonth
})
res, err := a.Check(p, "paid", 10)
if err != nil {
t.Fatal(err)
}
a.Record(res, store.Usage{KeyID: "k1", PromptTokens: 1000})
for range 3 {
if _, err := a.Check(p, "paid", 10); !errors.Is(err, ErrBudgetSpent) {
t.Fatalf("not refused: %v", err)
}
}
got := sent()
if len(got) != 1 || got[0].Kind != notify.BudgetSpent || !strings.Contains(got[0].Message, "month") {
t.Fatalf("sent %+v, want one budget_spent", got)
}
}
+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
+126
View File
@@ -0,0 +1,126 @@
package accounting
import (
"context"
"errors"
"log/slog"
"path/filepath"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// peopleStore has Acme/ml/training with two members, ayse and deniz.
func peopleStore(t *testing.T) (*store.DB, string) {
t.Helper()
ctx := context.Background()
s, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "p.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Close() })
org, _ := s.CreateOrg(ctx, "Acme")
team, _ := s.CreateTeam(ctx, org, "ml")
proj, _ := s.CreateProject(ctx, team, "training")
for _, id := range []string{"ayse", "deniz"} {
if err := s.CreateUser(ctx, store.User{ID: id, Email: id + "@x", Name: id,
PasswordHash: "h", State: store.UserPending, CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
if err := s.ApproveUser(ctx, id, proj, "test", time.Now()); err != nil {
t.Fatal(err)
}
}
return s, proj
}
// keyFor makes a key for a person and resolves it the way authentication does.
func keyFor(t *testing.T, s *store.DB, id, user, project string) auth.Principal {
t.Helper()
ctx := context.Background()
_, k, _ := store.NewKey(id, id, project)
if err := s.CreateUserKey(ctx, user, k); err != nil {
t.Fatal(err)
}
e, err := s.Resolve(ctx, id)
if err != nil {
t.Fatalf("resolving %s: %v", id, err)
}
return auth.Principal{KeyID: id, UserID: e.UserID, ProjectID: e.ProjectID, Limits: e}
}
func TestAPersonsLimitIsOnePoolAcrossTheirKeys(t *testing.T) {
s, proj := peopleStore(t)
if err := s.PutLimits(context.Background(),
store.Limits{Scope: store.ScopeUser, ScopeID: "ayse", RPM: intp(2)}); err != nil {
t.Fatal(err)
}
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
personal := keyFor(t, s, "k-personal", "ayse", "")
inProject := keyFor(t, s, "k-project", "ayse", proj)
// Two requests, one on each key, use up the person's two a minute.
for _, p := range []auth.Principal{personal, inProject} {
if _, err := a.Check(p, "free", 1); err != nil {
t.Fatalf("%s: %v", p.KeyID, err)
}
}
if _, err := a.Check(personal, "free", 1); !errors.Is(err, ErrRateLimited) {
t.Errorf("a third request across her keys was allowed: %v", err)
}
// Deniz is somebody else.
if _, err := a.Check(keyFor(t, s, "k-deniz", "deniz", proj), "free", 1); err != nil {
t.Errorf("another person was limited by hers: %v", err)
}
}
func TestAPerMemberLimitCountsEachMemberApart(t *testing.T) {
s, proj := peopleStore(t)
if err := s.PutLimits(context.Background(),
store.Limits{Scope: store.ScopeProjectMember, ScopeID: proj, RPM: intp(1)}); err != nil {
t.Fatal(err)
}
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
ayse := keyFor(t, s, "k-a", "ayse", proj)
deniz := keyFor(t, s, "k-d", "deniz", proj)
if _, err := a.Check(ayse, "free", 1); err != nil {
t.Fatal(err)
}
if _, err := a.Check(ayse, "free", 1); !errors.Is(err, ErrRateLimited) {
t.Errorf("her second request passed a one-a-minute per-member limit: %v", err)
}
// Her share is hers: Deniz still has his.
if _, err := a.Check(deniz, "free", 1); err != nil {
t.Errorf("one member's use counted against another: %v", err)
}
// And outside the project it does not apply at all.
if _, err := a.Check(keyFor(t, s, "k-a-own", "ayse", ""), "free", 1); err != nil {
t.Errorf("a project's per-member limit held her personal key: %v", err)
}
}
func TestAPersonsBudgetSurvivesARestart(t *testing.T) {
s, _ := peopleStore(t)
ctx := context.Background()
if err := s.PutLimits(ctx, store.Limits{Scope: store.ScopeUser, ScopeID: "ayse",
Budget: i64p(1000), BudgetPeriod: store.PeriodMonth}); err != nil {
t.Fatal(err)
}
// Spent before the restart, personally and on the chat page.
if err := s.WriteUsage(ctx, []store.Usage{
{At: time.Now(), KeyID: "k-old", UserID: "ayse", CostMicros: 600, Model: "paid", ServedModel: "paid"},
{At: time.Now(), KeyID: "session:ayse", UserID: "ayse", CostMicros: 500, Model: "paid", ServedModel: "paid"},
}); err != nil {
t.Fatal(err)
}
a := New(pricing(t), nil, slog.New(slog.DiscardHandler))
if err := a.RestoreBudgets(ctx, s, store.PeriodMonth); err != nil {
t.Fatal(err)
}
if _, err := a.Check(keyFor(t, s, "k-new", "ayse", ""), "paid", 1); !errors.Is(err, ErrBudgetSpent) {
t.Errorf("a restart forgot what she had spent: %v", err)
}
}
+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)
}
}
+708 -532
View File
File diff suppressed because it is too large. Load diff
+180 -11
View File
@@ -4,6 +4,7 @@ import (
"context"
"crypto/tls"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
@@ -22,6 +23,8 @@ import (
"golang.org/x/net/http2"
"golang.org/x/net/http2/h2c"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
@@ -34,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 {
@@ -58,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})
@@ -397,11 +407,16 @@ func TestHeartbeatKeepsNodeAlive(t *testing.T) {
n, _ := h.inv.Get("n1")
first := n.LastSeen
time.Sleep(200 * time.Millisecond)
n, _ = h.inv.Get("n1")
if !n.LastSeen.After(first) {
t.Error("heartbeats are not refreshing LastSeen; the node would be declared lost")
// Waited for rather than slept through: four heartbeat intervals was not
// always enough on a loaded runner for one to have landed.
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if n, _ = h.inv.Get("n1"); n.LastSeen.After(first) {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Error("heartbeats are not refreshing LastSeen; the node would be declared lost")
}
func TestStartAndStopShardEndToEnd(t *testing.T) {
@@ -656,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")
}
@@ -756,3 +771,157 @@ func TestAFabricRateIsTheSlowestActiveLink(t *testing.T) {
}
}
}
func TestAPortGoingDownIsToldToTheControlPlaneOnce(t *testing.T) {
// Checked only at registration, a port going down went unnoticed until
// the agent restarted, and the node went on being offered multi-node
// work that would hang on its first collective.
root := ibTreeWithActivePorts(t, "mlx5_0")
h := start(t, func(c *Config) {
c.Discovery.IBRoot = root
c.Fabrics = []inventory.FabricAttachment{{Fabric: "ib-a", HCAs: []string{"mlx5_0"}}}
})
if n, _ := h.inv.Get("n1"); !n.HasFabric("ib-a") {
t.Fatalf("the fabric should be advertised at registration: %+v", n.Fabrics)
}
hb := h.ag.heartbeat().GetHeartbeat()
if hb.GetFabricsChanged() || len(hb.GetIbPorts()) != 1 || hb.GetIbPorts()[0].GetState() != "ACTIVE" {
t.Fatalf("a quiet heartbeat should carry the port and no change: %+v", hb)
}
if err := os.WriteFile(filepath.Join(root, "mlx5_0", "ports", "1", "state"), []byte("1: DOWN\n"), 0o644); err != nil {
t.Fatal(err)
}
hb = h.ag.heartbeat().GetHeartbeat()
if !hb.GetFabricsChanged() || len(hb.GetFabrics()) != 0 {
t.Errorf("the port going down should withdraw the fabric: changed=%v fabrics=%v",
hb.GetFabricsChanged(), hb.GetFabrics())
}
if hb = h.ag.heartbeat().GetHeartbeat(); hb.GetFabricsChanged() {
t.Error("the change should be sent once, not on every heartbeat")
}
}
func TestAShardUsesThisNodesInterfaceAndRoCEIndex(t *testing.T) {
// The control plane sends one interface name for every node; a node whose
// management network is on another had NCCL bind nothing. And which RoCE
// GID entry routes is a property of this node's adapters.
a := &Agent{cfg: Config{MgmtInterface: "ens3"}}
a.inv.HCAs = []discovery.HCA{{Name: "mlx5_0", Ports: []discovery.Port{
{Num: 1, State: "ACTIVE", LinkLayer: discovery.LinkEthernet, RoCEv2GID: 3}}}}
spec := engines.ShardSpec{MgmtInterface: "eth0", HCAs: []string{"mlx5_0"}}
a.localizeSpec(&spec)
if spec.MgmtInterface != "ens3" {
t.Errorf("interface = %q", spec.MgmtInterface)
}
if spec.GIDIndex == nil || *spec.GIDIndex != 3 {
t.Errorf("GID index = %v", spec.GIDIndex)
}
ib := engines.ShardSpec{HCAs: []string{"mlx5_1"}}
a.inv.HCAs = append(a.inv.HCAs, discovery.HCA{Name: "mlx5_1", Ports: []discovery.Port{
{Num: 1, State: "ACTIVE", LinkLayer: discovery.LinkInfiniBand, RoCEv2GID: -1}}})
a.localizeSpec(&ib)
if ib.GIDIndex != nil {
t.Error("an InfiniBand shard was given a GID index")
}
}
func TestTheManagementInterfaceIsFoundNotAssumed(t *testing.T) {
// It was defaulted to eth0 before it could be looked for, so it never
// was: a node on ens3 or bond0 had NCCL bootstrap over nothing.
cfg := DefaultConfig()
cfg.Server = "http://127.0.0.1:7443"
cfg.NodeID = "n1"
cfg.MgmtAddr = "127.0.0.1"
if err := cfg.normalize(); err != nil {
t.Fatal(err)
}
if cfg.MgmtInterface == "" || cfg.MgmtInterface == "eth0" {
t.Errorf("interface = %q, want the loopback interface holding 127.0.0.1", cfg.MgmtInterface)
}
}
func TestEachFabricsSubnetManagersAreAskedOnce(t *testing.T) {
// Two adapters on one fabric see one master. Its managers are listed
// through one of them — the same list twice is a query too many — and
// every port says which master it sees.
oldSM, oldSMs := querySM, querySMs
t.Cleanup(func() { querySM, querySMs = oldSM, oldSMs })
querySM = func(string, int) (*discovery.SMInfo, error) {
return &discovery.SMInfo{GUID: "0x01", LID: 1, State: "SMINFO_MASTER"}, nil
}
lists := 0
querySMs = func(string, int) ([]discovery.SMRecord, error) {
lists++
return []discovery.SMRecord{{GUID: "0x01", State: "master"}, {GUID: "0x02", State: "standby"}}, nil
}
h := start(t, func(c *Config) {
c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0", "mlx5_1")
c.Fabrics = []inventory.FabricAttachment{{Fabric: "ib-a", HCAs: []string{"mlx5_0", "mlx5_1"}}}
})
hb := h.ag.heartbeat().GetHeartbeat()
withList := 0
for _, p := range hb.GetIbPorts() {
if p.GetSmGuid() != "0x01" {
t.Errorf("%s sees master %q", p.GetHca(), p.GetSmGuid())
}
if len(p.GetSubnetManagers()) > 0 {
withList++
}
}
if lists != 1 || withList != 1 {
t.Errorf("listed %d times, carried on %d ports; want once, on one", lists, withList)
}
}
func TestAFabricScanIsAskedForAndAnswered(t *testing.T) {
// One node per fabric maps it; the control plane asks and parses.
old := scanFabric
t.Cleanup(func() { scanFabric = old })
var asked string
scanFabric = func(_ context.Context, hca string, port int) (string, error) {
asked = fmt.Sprintf("%s/%d", hca, port)
return "Switch\t36 \"S-00000000000000a1\"\t\t# \"MF0;leaf-a:QM8700/U1\" enhanced port 0 lid 1 lmc 0\n", nil
}
h := start(t)
r, err := h.svc.ScanFabric(t.Context(), "n1", "mlx5_0", 1)
if err != nil {
t.Fatal(err)
}
if asked != "mlx5_0/1" || !strings.Contains(r.GetOutput(), "leaf-a") || r.GetError() != "" {
t.Errorf("asked %q, got %+v", asked, r)
}
}
func TestABandwidthTestIsRunOnTheAdapterAskedFor(t *testing.T) {
oldRun, oldHave := runWriteBW, haveWriteBW
t.Cleanup(func() { runWriteBW, haveWriteBW = oldRun, oldHave })
haveWriteBW = func() bool { return true }
var got discovery.WriteBW
runWriteBW = func(_ context.Context, w discovery.WriteBW) (float64, string, error) {
got = w
return 388.2, " #bytes #iterations BW peak[Gb/sec] BW average[Gb/sec]\n", nil
}
h := start(t, func(c *Config) { c.Discovery.IBRoot = ibTreeWithActivePorts(t, "mlx5_0") })
r, err := h.svc.FabricTest(t.Context(), "n1", &llmgwv1.FabricTestRequest{
Role: "client", Hca: "mlx5_0", Port: 1, Peer: "10.0.0.2", TcpPort: 18515, Seconds: 3})
if err != nil {
t.Fatal(err)
}
if r.GetGbps() != 388.2 || r.GetError() != "" {
t.Errorf("reply %+v", r)
}
if got.HCA != "mlx5_0" || got.Peer != "10.0.0.2" || got.TCPPort != 18515 || got.GIDIndex != -1 {
t.Errorf("ran %+v", got)
}
// An adapter the node does not have is said so, without running anything.
got = discovery.WriteBW{}
r, _ = h.svc.FabricTest(t.Context(), "n1", &llmgwv1.FabricTestRequest{Role: "server", Hca: "mlx5_9", Port: 1, Seconds: 1})
if !strings.Contains(r.GetError(), "no port mlx5_9/1") || got.HCA != "" {
t.Errorf("reply %+v, ran %+v", r, got)
}
}
+30
View File
@@ -0,0 +1,30 @@
package agent
import (
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
)
func TestEnginesListenOnTheManagementAddressWhenItIsThisMachines(t *testing.T) {
cases := map[string]string{
"127.0.0.1": "127.0.0.1", // one of this machine's
"203.0.113.9": "", // not: every address rather than none
"gpu07.lan": "", // a name
"0.0.0.0": "",
"": "",
}
for mgmt, want := range cases {
a := &Agent{cfg: Config{MgmtAddr: mgmt}}
if got := a.bindAddr(); got != want {
t.Errorf("management address %q: listens on %q, expected %q", mgmt, got, want)
}
}
a := &Agent{}
if got := a.engineBase(engines.ShardSpec{HTTPPort: 8000}); got != "http://127.0.0.1:8000" {
t.Errorf("on every address, asked at %s", got)
}
if got := a.engineBase(engines.ShardSpec{HTTPPort: 8000, BindAddr: "10.0.0.147"}); got != "http://10.0.0.147:8000" {
t.Errorf("on one address, asked at %s", got)
}
}
+299
View File
@@ -0,0 +1,299 @@
package agent
import (
"errors"
"fmt"
"net"
"net/url"
"os"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/obs"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
// Config configures the agent.
type Config struct {
// NodeID defaults to the hostname. It must equal the common name of the
// agent's certificate, so set it only where the two differ.
NodeID string `koanf:"node_id"`
// Server is the control plane, and Servers is the same thing when there is
// more than one. Both are accepted: every configuration written before
// there could be a second control plane names one address, and none of
// them should have to change.
//
// The order is tried in order, and a refusal moves to the next. Only the
// leader accepts an agent, so a standby is skipped in the time it takes to
// be told so — which is what makes failover a reconnect rather than an
// edit to every node's configuration.
Server string `koanf:"server"`
Servers []string `koanf:"servers"`
// Hostname defaults to the machine's.
Hostname string `koanf:"hostname"`
// MgmtAddr is the address engines are reached on. It defaults to this
// machine's address on the route to the control plane, which is the one
// the control plane and the router can reach.
MgmtAddr string `koanf:"mgmt_addr"`
// Labels are free-form facts about a node for a profile's node_selector.
// Nothing needs them: memory, GPUs, engines and disk are measured, and a
// label restating one of those goes stale the day the machine changes.
Labels map[string]string `koanf:"labels"`
// MgmtInterface is the ethernet interface NCCL bootstraps over. It
// defaults to the interface holding MgmtAddr, then eth0.
MgmtInterface string `koanf:"mgmt_interface"`
// Fabrics labels this node's HCAs. It is explicit on purpose: OpenSM's
// default subnet prefix is the same on every fabric, so nothing can be
// inferred from the wire (plan §5.2).
Fabrics []inventory.FabricAttachment `koanf:"fabrics"`
HeartbeatInterval time.Duration `koanf:"heartbeat_interval"`
// ReconnectBase is the first reconnect delay; it backs off from there.
ReconnectBase time.Duration `koanf:"reconnect_base"`
ReconnectMax time.Duration `koanf:"reconnect_max"`
// AdoptReadyTimeout bounds how long an adopted engine is given to answer a
// health check before the agent stops waiting on it.
//
// Shorter than a startup timeout on purpose: this engine is already
// running, so it is not loading a model, it is either serving or it is not.
AdoptReadyTimeout time.Duration `koanf:"adopt_ready_timeout"`
// TLS authenticates both ends of the control-plane stream. Leaving it unset
// keeps the plaintext behaviour, which only belongs on a trusted network.
TLS tlsconf.Config `koanf:"tls"`
// ModelDirs are the directories this node keeps model weights in, beyond
// WeightsCache, which is always looked in. Storage differs at every installation,
// so llmgw observes it rather than owning it: a Lustre mount, an image
// exported over the fabric, local NVMe and a copy made by hand all look
// identical from here, and placement only needs to know whether the file
// is present.
ModelDirs []string `koanf:"model_dirs"`
// EnginesDir is where the agent installs engines, one directory per
// build. Defaults to /opt/llmgw/engines.
EnginesDir string `koanf:"engines_dir"`
// WeightsCache is where a remote source is downloaded to. Defaults to the
// first entry of ModelDirs, because a node that says where its models live
// has already answered this question, and otherwise to
// /var/lib/llmgw-weights.
//
// Fetching is the simple arrangement: every node keeps its own copy. The
// exotic ones need no code — weights on a shared filer or an image exported
// over the fabric are simply present, and present means nothing is fetched.
WeightsCache string `koanf:"weights_cache"`
Discovery discovery.Options `koanf:"discovery"`
Engines engines.Config `koanf:"engines"`
Superv supervisor.Config `koanf:"supervisor"`
// Tracing sends this node's part of each replica start — the weights
// fetched, the engine launched, the wait for it to answer — to an
// OpenTelemetry collector, inside the control plane's trace of that
// start. Give it the collector server.yaml's tracing names. Off unless
// an endpoint is set here or in OTEL_EXPORTER_OTLP_ENDPOINT.
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.
func DefaultConfig() Config {
// No MgmtInterface: normalize finds the one holding the management
// address, and falls back to eth0 only when it cannot. Defaulted here, it
// was never found — every node said eth0 unless told otherwise, and a node
// on ens3 or bond0 had NCCL bootstrap over an interface it does not have.
return Config{
HeartbeatInterval: 5 * time.Second,
ReconnectBase: time.Second,
ReconnectMax: 30 * time.Second,
AdoptReadyTimeout: 60 * time.Second,
Superv: supervisor.DefaultConfig(),
// NVML gives NVLink topology, so prefer it when this build has it.
Discovery: discovery.Options{PreferNVML: true},
}
}
func (c *Config) normalize() error {
d := DefaultConfig()
if err := c.TLS.Validate(); err != nil {
return err
}
if err := c.normalizeServerURL(); err != nil {
return err
}
// Everything below is found rather than written. A file that has to say
// which machine it is on, and at what address, is a file that differs on
// every node — and a copy taken from one node to the next registers the
// second as the first.
host, _ := os.Hostname()
if c.NodeID == "" {
c.NodeID = host
}
if c.NodeID == "" {
return errors.New("node_id is not set and the hostname could not be read")
}
if c.Hostname == "" {
c.Hostname = host
}
if c.MgmtAddr == "" {
c.MgmtAddr = addrToward(c.Server)
}
if c.MgmtInterface == "" {
c.MgmtInterface = interfaceOf(c.MgmtAddr)
}
if c.MgmtInterface == "" {
c.MgmtInterface = "eth0"
}
// The directory downloads land in is always one that is looked in. When
// they were two settings, a node could fill its disk with fetched weights
// that placement never saw — which is what every node here was doing.
if c.WeightsCache == "" && len(c.ModelDirs) > 0 {
c.WeightsCache = c.ModelDirs[0]
}
if c.WeightsCache == "" {
c.WeightsCache = DefaultWeightsCache
}
if !slices.Contains(c.ModelDirs, c.WeightsCache) {
c.ModelDirs = append(c.ModelDirs, c.WeightsCache)
}
if c.HeartbeatInterval <= 0 {
c.HeartbeatInterval = d.HeartbeatInterval
}
if c.ReconnectBase <= 0 {
c.ReconnectBase = d.ReconnectBase
}
if c.AdoptReadyTimeout <= 0 {
c.AdoptReadyTimeout = d.AdoptReadyTimeout
}
if c.ReconnectMax < c.ReconnectBase {
c.ReconnectMax = max(c.ReconnectBase, d.ReconnectMax)
}
return nil
}
// normalizeServerURL makes the configured address a URL connect can use, and
// refuses the combination that would quietly undo the certificates: a scheme
// that says plaintext while TLS is configured. Silently connecting anyway would
// mean a cluster believing it has mutual authentication when it has none.
func (c *Config) normalizeServerURL() error {
// One list, however it was written. Server first when both are given,
// because an operator adding a standby to an existing file expects the
// one already there to stay preferred.
all := make([]string, 0, 1+len(c.Servers))
if c.Server != "" {
all = append(all, c.Server)
}
all = append(all, c.Servers...)
seen := map[string]struct{}{}
out := make([]string, 0, len(all))
for _, s := range all {
fixed, err := normalizeOne(s, c.TLS.Enabled())
if err != nil {
return err
}
// A duplicate would be tried twice in a row on failover, which reads
// as the rotation not working.
if _, dup := seen[fixed]; dup {
continue
}
seen[fixed] = struct{}{}
out = append(out, fixed)
}
if len(out) == 0 {
return errors.New("server is required")
}
c.Servers = out
c.Server = out[0]
return nil
}
// DefaultWeightsCache is where fetched weights go when nothing says otherwise.
const DefaultWeightsCache = "/var/lib/llmgw-weights"
// addrToward is this machine's address on the route to the control plane.
//
// The address engines are reached on is whichever one the control plane can
// reach, and the kernel already knows which that is: a UDP "connection" picks
// the source address for the route without sending a packet. Asking the
// interfaces instead would have to guess among docker bridges, VPNs and
// link-local addresses.
func addrToward(server string) string {
u, err := url.Parse(server)
if err != nil || u.Host == "" {
return ""
}
host := u.Hostname()
port := u.Port()
if port == "" {
port = "443"
}
conn, err := net.Dial("udp", net.JoinHostPort(host, port))
if err != nil {
return ""
}
defer conn.Close()
if a, ok := conn.LocalAddr().(*net.UDPAddr); ok {
return a.IP.String()
}
return ""
}
// interfaceOf names the interface that holds an address, or "" when none does.
func interfaceOf(addr string) string {
ip := net.ParseIP(addr)
if ip == nil {
return ""
}
ifaces, err := net.Interfaces()
if err != nil {
return ""
}
for _, ifc := range ifaces {
addrs, err := ifc.Addrs()
if err != nil {
continue
}
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.Equal(ip) {
return ifc.Name
}
}
}
return ""
}
// normalizeOne applies the scheme rules to a single address.
func normalizeOne(addr string, secure bool) (string, error) {
switch {
case strings.HasPrefix(addr, "https://"):
if !secure {
return "", fmt.Errorf("%s is an https:// address but no tls certificates are configured", addr)
}
case strings.HasPrefix(addr, "http://"):
if secure {
return "", fmt.Errorf("tls is configured but %s is an http:// address; use https://", addr)
}
case strings.Contains(addr, "://"):
return "", fmt.Errorf("server %q must be an http:// or https:// address", addr)
case secure:
return "https://" + addr, nil
default:
return "http://" + addr, nil
}
return addr, nil
}
+77
View File
@@ -0,0 +1,77 @@
package agent
import (
"encoding/json"
"os"
"path/filepath"
"slices"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
// controlPlanesFile keeps the control planes the leader last named, beside
// the agent's configuration rather than in it: the configuration is the
// operator's, and a list rewritten in it would be a file nobody wrote.
var controlPlanesFile = "/var/lib/llmgw-agent/control-planes.json"
// servers is every address to dial, in the order to try them: the
// configuration's, then any the leader named that it lacks.
func (a *Agent) servers() []string {
a.mu.Lock()
defer a.mu.Unlock()
if len(a.serverList) == 0 {
return a.cfg.Servers
}
return a.serverList
}
// mergeServers puts the leader's list after the configured one, without
// repeats, and says whether that changed anything.
func mergeServers(configured, told []string, secure bool) []string {
out := slices.Clone(configured)
for _, s := range told {
fixed, err := normalizeOne(s, secure)
if err != nil {
continue
}
if !slices.Contains(out, fixed) {
out = append(out, fixed)
}
}
return out
}
// setControlPlanes takes the leader's list: used from the next reconnect,
// and kept for the next start.
func (a *Agent) setControlPlanes(m *llmgwv1.ControlPlanes) {
told := m.GetServers()
merged := mergeServers(a.cfg.Servers, told, a.cfg.TLS.Enabled())
a.mu.Lock()
changed := !slices.Equal(merged, a.serverList)
a.serverList = merged
a.mu.Unlock()
if !changed {
return
}
a.log.Info("the control planes to dial", "servers", merged)
b, _ := json.Marshal(told)
if err := os.MkdirAll(filepath.Dir(controlPlanesFile), 0o755); err == nil {
tmp := controlPlanesFile + ".new"
if err := os.WriteFile(tmp, b, 0o644); err == nil {
_ = os.Rename(tmp, controlPlanesFile)
}
}
}
// loadControlPlanes is the list kept from the last time the leader named it.
func loadControlPlanes(configured []string, secure bool) []string {
b, err := os.ReadFile(controlPlanesFile)
if err != nil {
return configured
}
var told []string
if json.Unmarshal(b, &told) != nil {
return configured
}
return mergeServers(configured, told, secure)
}
+41
View File
@@ -0,0 +1,41 @@
package agent
import (
"log/slog"
"path/filepath"
"slices"
"testing"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
func TestAControlPlaneTheLeaderNamesIsDialledAndRemembered(t *testing.T) {
old := controlPlanesFile
controlPlanesFile = filepath.Join(t.TempDir(), "control-planes.json")
t.Cleanup(func() { controlPlanesFile = old })
configured := []string{"https://10.0.0.140:7443"}
// TLS on, as every onboarded node has it: an agent without it refuses
// https:// addresses, and the leader's are https://.
a := &Agent{cfg: Config{Servers: configured, TLS: tlsconf.Config{CA: "/etc/llmgw/ca.pem"}}, log: slog.New(slog.DiscardHandler)}
if got := a.servers(); !slices.Equal(got, configured) {
t.Fatalf("before anything is named: %v", got)
}
// The configured one stays first; the new one is added after it, once.
a.setControlPlanes(&llmgwv1.ControlPlanes{Servers: []string{"https://10.0.0.140:7443", "https://10.0.0.150:7443"}})
want := []string{"https://10.0.0.140:7443", "https://10.0.0.150:7443"}
if got := a.servers(); !slices.Equal(got, want) {
t.Errorf("after the leader named two: %v", got)
}
// And after a restart, before the leader says anything.
if got := loadControlPlanes(configured, true); !slices.Equal(got, want) {
t.Errorf("after a restart: %v", got)
}
// A list that drops the configured one does not drop it: the
// configuration is the operator's.
a.setControlPlanes(&llmgwv1.ControlPlanes{Servers: []string{"https://10.0.0.150:7443"}})
if got := a.servers(); got[0] != "https://10.0.0.140:7443" {
t.Errorf("the configured server was dropped: %v", got)
}
}
+32
View File
@@ -0,0 +1,32 @@
package agent
import (
"testing"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
)
func TestAnEngineDisabledOnTheNodeIsNeitherInstalledNorAdvertised(t *testing.T) {
a := &Agent{}
a.cfg.Engines.Disabled = []string{"vllm"}
a.setProvision(&llmgwv1.Provision{Engines: []*llmgwv1.EngineWant{
{Engine: "llamacpp", Version: "b1"}, {Engine: "vllm", Version: "0.30.0"},
}})
if len(a.provWants) != 1 || a.provWants[0].Engine != "llamacpp" {
t.Errorf("wants %+v: vLLM is disabled here and must not be installed", a.provWants)
}
a.configuredVers = map[string]string{"vllm": "0.25.0"}
a.installedVers = map[string]string{"llamacpp": "b1"}
if got := a.engineReportLocked(); got["vllm"] != "" || got["llamacpp"] != "b1" {
t.Errorf("advertised %v: a disabled engine found on disk is still not offered", got)
}
}
func TestAnEngineNamedByHandIsNotInstalledOverIt(t *testing.T) {
a := &Agent{reg: engines.NewRegistry(engines.Config{LlamaCpp: engines.LlamaCppConfig{Bin: "/opt/own/llama-server"}})}
a.setProvision(&llmgwv1.Provision{Engines: []*llmgwv1.EngineWant{{Engine: "llamacpp", Version: "b1"}}})
if len(a.provWants) != 0 {
t.Errorf("wants %+v: an installed build would be used over the operator's own", a.provWants)
}
}
+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)
}
}
+29 -4
View File
@@ -21,12 +21,18 @@ type Options struct {
NvidiaSMI string `koanf:"nvidia_smi"`
// IBRoot is the sysfs InfiniBand directory.
IBRoot string `koanf:"ib_root"`
// PreferNVML uses the NVML backend when this binary has one. It is the only
// source of NVLink topology.
// PreferNVML uses the NVML backend when this binary has one. NVLink
// topology is read from nvidia-smi topo -m on every build; NVML, where it
// is used, is the more exact source of the peers.
PreferNVML bool `koanf:"prefer_nvml"`
// SkipGPUs omits GPU discovery. 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:"-"`
}
@@ -64,8 +70,7 @@ func Discover(ctx context.Context, opts Options, log *slog.Logger) (Inventory, e
inv.GPUs, inv.Source = gpus, "nvml"
break
}
log.Warn("NVML discovery failed, falling back to nvidia-smi; "+
"NVLink topology will be unavailable", "err", err)
log.Warn("NVML discovery failed, falling back to nvidia-smi", "err", err)
fallthrough
default:
gpus, err := DiscoverGPUsSMI(ctx, opts.NvidiaSMI)
@@ -74,6 +79,26 @@ func Discover(ctx context.Context, opts Options, log *slog.Logger) (Inventory, e
}
inv.GPUs, inv.Source = gpus, "nvidia-smi"
}
// NVLink and which adapter sits nearest each card, for either path:
// NVML knows NVLink but not the adapters, nvidia-smi's query knows
// neither. A failure costs the topology and nothing else — the cards
// are still cards.
if len(inv.GPUs) > 0 {
topo, err := DiscoverTopology(ctx, opts.NvidiaSMI)
if err != nil {
log.Warn("could not read the GPU topology; tensor-parallel groups "+
"will be chosen without NVLink", "err", err)
} else {
topo.Apply(inv.GPUs)
}
// Multi-node NVLink: which domain each card has joined.
if fab, err := DiscoverGPUFabric(ctx, opts.NvidiaSMI); err != nil {
log.Warn("could not read the GPUs' NVLink fabric state", "err", err)
} else {
ApplyFabric(inv.GPUs, fab)
}
inv.IMEX = DiscoverIMEX(ctx)
}
}
hcas, err := DiscoverIB(opts.IBRoot)
+3 -1
View File
@@ -10,6 +10,8 @@ import (
// has.
type FabricCheck struct {
Fabric string
// Declared are the adapters the configuration names for it.
Declared []string
// Usable are the declared adapters that exist and have an active port.
Usable []string
// RateGbps is the slowest active port among them, because a collective
@@ -49,7 +51,7 @@ func CheckFabrics(declared map[string][]string, discovered []HCA) []FabricCheck
out := make([]FabricCheck, 0, len(names))
for _, fabric := range names {
c := FabricCheck{Fabric: fabric}
c := FabricCheck{Fabric: fabric, Declared: declared[fabric]}
for _, want := range declared[fabric] {
hca, found := byName[want]
if !found {
+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
}
+41 -2
View File
@@ -38,8 +38,22 @@ type GPU struct {
// NVLinkPeers are the indices of GPUs reachable over NVLink. Within a node
// a TP group should stay inside one NVLink domain.
NVLinkPeers []int `json:"nvlink_peers,omitempty"`
// NICPaths is how far each network adapter is from this GPU, by HCA name:
// PIX, PXB, PHB, NODE or SYS, as nvidia-smi topo reports it. What decides
// which adapter a GPU's share of a multi-node job should go through.
NICPaths map[string]string `json:"nic_paths,omitempty"`
// Fabric is the GPU's multi-node NVLink membership, nil where the
// driver reports none.
Fabric *GPUFabric `json:"fabric,omitempty"`
// Health carries anything that should keep the GPU out of placement.
Health GPUHealth `json:"health"`
// MIG is set when this is a MIG instance rather than a whole card: its
// Index is then MIGIndex's, its UUID the instance's (MIG-…), and its
// 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.
@@ -87,8 +101,30 @@ func (g GPU) Class() string {
}
// Round to the nearest GiB: vendors report 81559 MiB for an "80GB" card.
gb := int(math.Round(float64(g.MemoryTotalMB) / 1024))
return fmt.Sprintf("%s-%dg", model, gb)
// A MIG instance is named from its card's memory and then its profile,
// so two instances are one class only when both the card and the slice
// are the same.
mem := g.MemoryTotalMB
if g.MIG != nil {
mem = g.MIG.ParentMemoryTotalMB
}
gb := int(math.Round(float64(mem) / 1024))
class := fmt.Sprintf("%s-%dg", model, gb)
if g.MIG != nil {
return migClass(class, g.MIG.Profile)
}
return class
}
// normalizeBusID makes bus ids comparable: NVML reports them uppercase from one
// call and lowercase from another, and sometimes with a shortened domain.
func normalizeBusID(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
// "0000:1b:00.0" and "00000000:1B:00.0" are the same device.
if i := strings.Index(s, ":"); i > 0 {
s = strings.TrimLeft(s[:i], "0") + s[i:]
}
return strings.TrimPrefix(s, ":")
}
// MarshalJSON adds the derived class, so a consumer of the JSON does not have
@@ -117,6 +153,9 @@ type Inventory struct {
// Storage is the disk behind those weights, one entry per filesystem.
// Empty means the same thing it means for Weights: not asked.
Storage []Storage `json:"storage,omitempty"`
// IMEX is the service a multi-node NVLink domain needs, read where the
// node has GPUs.
IMEX IMEX `json:"imex"`
}
// Classes returns the distinct GPU classes present, sorted.
+160
View File
@@ -0,0 +1,160 @@
package discovery
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
// GPUFabric is a GPU's place in a multi-node NVLink domain, as the driver
// reports it: the NVL72 rack, or the NVLink Switch system, it belongs to.
type GPUFabric struct {
// State is Completed once the fabric manager has joined the GPU to its
// domain; In Progress, Not Started or Not Supported otherwise.
State string `json:"state"`
// Status is Success, or the reason the join failed.
Status string `json:"status,omitempty"`
// ClusterUUID and CliqueID name the domain: GPUs sharing both can reach
// each other over NVLink whatever node they are in.
ClusterUUID string `json:"cluster_uuid,omitempty"`
CliqueID string `json:"clique_id,omitempty"`
// Bandwidth is Full, or how the domain's NVLink is degraded.
Bandwidth string `json:"bandwidth,omitempty"`
}
// Domain is the NVLink domain the GPU is joined to, empty when it is in none
// that crosses nodes: a card with no fabric, one not joined, or one whose
// cluster is all zeros, which is what a single machine's NVSwitch reports.
func (f *GPUFabric) Domain() string {
if f == nil || !strings.EqualFold(f.State, "Completed") {
return ""
}
u := strings.ToLower(strings.TrimSpace(f.ClusterUUID))
if u == "" || strings.Trim(u, "0-") == "" {
return ""
}
return u + "/" + f.CliqueID
}
// ParseGPUFabric reads the Fabric sections of `nvidia-smi -q`, by the PCI
// address each GPU's section starts with.
func ParseGPUFabric(out string) map[string]GPUFabric {
res := map[string]GPUFabric{}
var bus string
fabricIndent := -1
var cur *GPUFabric
flush := func() {
if bus != "" && cur != nil && cur.State != "" {
res[bus] = *cur
}
cur, fabricIndent = nil, -1
}
for _, line := range strings.Split(out, "\n") {
trim := strings.TrimSpace(line)
indent := len(line) - len(strings.TrimLeft(line, " \t"))
if strings.HasPrefix(line, "GPU ") && indent == 0 {
flush()
bus = strings.ToLower(strings.TrimSpace(strings.TrimPrefix(line, "GPU ")))
continue
}
if trim == "Fabric" {
flush()
cur, fabricIndent = &GPUFabric{}, indent
continue
}
if cur == nil {
continue
}
if trim != "" && indent <= fabricIndent {
flush()
continue
}
k, v, ok := strings.Cut(trim, ":")
if !ok {
continue
}
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
if strings.EqualFold(v, "N/A") {
v = ""
}
switch k {
case "State":
cur.State = v
case "Status":
cur.Status = v
case "ClusterUUID", "Cluster UUID":
cur.ClusterUUID = v
case "CliqueId", "Clique ID":
cur.CliqueID = v
case "Bandwidth":
cur.Bandwidth = v
}
}
flush()
return res
}
// DiscoverGPUFabric runs `nvidia-smi -q` for the Fabric sections. Nothing
// is reported on a driver or a card without them.
func DiscoverGPUFabric(ctx context.Context, binary string) (map[string]GPUFabric, error) {
if binary == "" {
if _, err := exec.LookPath("nvidia-smi"); err != nil {
return nil, nil
}
binary = "nvidia-smi"
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, binary, "-q").Output()
if err != nil {
return nil, err
}
return ParseGPUFabric(string(out)), nil
}
// ApplyFabric puts each GPU's fabric on it, matched by PCI address.
func ApplyFabric(gpus []GPU, fab map[string]GPUFabric) {
for i := range gpus {
if f, ok := fab[strings.ToLower(gpus[i].PCIBusID)]; ok {
f := f
gpus[i].Fabric = &f
}
}
}
// IMEX is the service that lets GPUs in different nodes of one NVLink
// domain share memory: without it, multi-node NVLink carries nothing.
type IMEX struct {
// State is systemd's word for nvidia-imex: active, inactive, or empty
// where it is not installed.
State string `json:"state,omitempty"`
// Channels is how many IMEX channels exist; a process needs one.
Channels int `json:"channels"`
}
// DiscoverIMEX reads the IMEX service and its channels.
func DiscoverIMEX(ctx context.Context) IMEX {
var m IMEX
if entries, err := os.ReadDir("/dev/nvidia-caps-imex-channels"); err == nil {
m.Channels = len(entries)
}
if _, err := exec.LookPath("systemctl"); err == nil {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
out, _ := exec.CommandContext(ctx, "systemctl", "is-active", "nvidia-imex").Output()
m.State = strings.TrimSpace(string(out))
if m.State == "inactive" {
// "inactive" is also what systemd says of a unit that does not
// exist; the binary says which.
if _, err := exec.LookPath("nvidia-imex"); err != nil {
if _, err := os.Stat(filepath.Join("/usr/bin", "nvidia-imex")); err != nil {
m.State = ""
}
}
}
}
return m
}
@@ -0,0 +1,77 @@
package discovery
import "testing"
// smiQ is an excerpt of `nvidia-smi -q` on a GB200 tray: two GPUs joined to
// one NVL72 domain, then a PCIe card that has no fabric at all.
const smiQ = `
==============NVSMI LOG==============
Timestamp : Mon Sep 29 10:00:00 2026
Driver Version : 570.86.15
Attached GPUs : 3
GPU 00000008:01:00.0
Product Name : NVIDIA GB200
Minor Number : 0
Fabric
State : Completed
Status : Success
CliqueId : 32766
ClusterUUID : 76B1AD76-7c24-4b6b-9f6c-2a1e0d8c11aa
Health
Bandwidth : Full
Processes : None
GPU 00000009:01:00.0
Product Name : NVIDIA GB200
Fabric
State : In Progress
Status : N/A
CliqueId : N/A
ClusterUUID : N/A
Processes : None
GPU 0000000A:01:00.0
Product Name : NVIDIA L40S
Fabric
State : N/A
Status : N/A
`
func TestAGPUsNVLinkDomainIsRead(t *testing.T) {
fab := ParseGPUFabric(smiQ)
a, ok := fab["00000008:01:00.0"]
if !ok {
t.Fatalf("fabric = %+v", fab)
}
if a.State != "Completed" || a.CliqueID != "32766" || a.Bandwidth != "Full" {
t.Errorf("GB200 = %+v", a)
}
if got := a.Domain(); got != "76b1ad76-7c24-4b6b-9f6c-2a1e0d8c11aa/32766" {
t.Errorf("domain = %q", got)
}
// Still joining: no domain yet, and said as such.
b := fab["00000009:01:00.0"]
if b.State != "In Progress" || b.Domain() != "" {
t.Errorf("joining GPU = %+v", b)
}
// A card with no fabric reports nothing rather than an empty one.
if _, ok := fab["0000000a:01:00.0"]; ok {
t.Error("a card without a fabric was given one")
}
gpus := []GPU{{Index: 0, PCIBusID: "00000008:01:00.0"}, {Index: 1, PCIBusID: "0000000A:01:00.0"}}
ApplyFabric(gpus, fab)
if gpus[0].Fabric == nil || gpus[1].Fabric != nil {
t.Errorf("applied = %+v %+v", gpus[0].Fabric, gpus[1].Fabric)
}
}
func TestASingleMachinesNVSwitchIsNoDomain(t *testing.T) {
// An eight-GPU HGX reports its fabric joined, with a cluster of zeros: its
// NVLink ends at the chassis.
f := &GPUFabric{State: "Completed", ClusterUUID: "00000000-0000-0000-0000-000000000000", CliqueID: "0"}
if f.Domain() != "" {
t.Errorf("a single machine read as a domain: %q", f.Domain())
}
}
+90 -11
View File
@@ -14,7 +14,6 @@ package discovery
import (
"context"
"fmt"
"strings"
"github.com/NVIDIA/go-nvml/pkg/nvml"
)
@@ -68,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)
}
@@ -83,11 +95,18 @@ func DiscoverGPUsNVML(_ context.Context) ([]GPU, error) {
}
assessHealth(&g)
gpus = append(gpus, g)
// A card in MIG mode is its instances, not itself: CUDA will not
// open the whole card while MIG is on.
devs, _ := migFromNVML(g, nvmlCard{dev})
gpus = append(gpus, devs...)
}
// A second pass, because a link's remote end may be a GPU enumerated later.
// A second pass, because a link's remote end may be a GPU enumerated
// later. MIG instances have no NVLink of their own to use.
for i := range gpus {
if gpus[i].IsMIG() {
continue
}
dev, ret := nvml.DeviceGetHandleByIndex(gpus[i].Index)
if ret != nvml.SUCCESS {
continue
@@ -136,13 +155,73 @@ func busIDString(b []int8) string {
return string(buf)
}
// normalizeBusID makes bus ids comparable: NVML reports them uppercase from one
// call and lowercase from another, and sometimes with a shortened domain.
func normalizeBusID(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
// "0000:1b:00.0" and "00000000:1B:00.0" are the same device.
if i := strings.Index(s, ":"); i > 0 {
s = strings.TrimLeft(s[:i], "0") + s[i:]
// nvmlCard adapts an NVML device to what MIG discovery reads of it.
type nvmlCard struct{ dev nvml.Device }
func (c nvmlCard) MIGMode() (current, pending, ok bool) {
cur, pend, ret := c.dev.GetMigMode()
if ret != nvml.SUCCESS {
// NOT_SUPPORTED on a card without MIG, which is most of them.
return false, false, false
}
return strings.TrimPrefix(s, ":")
return cur == nvml.DEVICE_MIG_ENABLE, pend == nvml.DEVICE_MIG_ENABLE, true
}
func (c nvmlCard) MaxMIGDevices() int {
n, ret := c.dev.GetMaxMigDeviceCount()
if ret != nvml.SUCCESS {
return 0
}
return n
}
func (c nvmlCard) MIGDevice(i int) (nvmlMIGInstance, bool) {
h, ret := c.dev.GetMigDeviceHandleByIndex(i)
if ret != nvml.SUCCESS {
// NOT_FOUND for an index with no instance: the count is the most a
// card can hold, not how many it has.
return nil, false
}
return nvmlMIG{h}, true
}
// nvmlMIG adapts a MIG device handle.
type nvmlMIG struct{ dev nvml.Device }
func (m nvmlMIG) UUID() string {
u, _ := m.dev.GetUUID()
return u
}
func (m nvmlMIG) Name() string {
n, _ := m.dev.GetName()
return n
}
func (m nvmlMIG) Memory() (totalMB, usedMB int, ok bool) {
mem, ret := m.dev.GetMemoryInfo()
if ret != nvml.SUCCESS {
return 0, 0, false
}
return int(mem.Total / (1 << 20)), int(mem.Used / (1 << 20)), true
}
func (m nvmlMIG) Instances() (gpuInstance, computeInstance int) {
gi, ret := m.dev.GetGpuInstanceId()
if ret != nvml.SUCCESS {
gi = -1
}
ci, ret := m.dev.GetComputeInstanceId()
if ret != nvml.SUCCESS {
ci = -1
}
return gi, ci
}
func (m nvmlMIG) Attributes() (slices, memoryMB int, ok bool) {
a, ret := m.dev.GetAttributes()
if ret != nvml.SUCCESS {
return 0, 0, false
}
return int(a.GpuInstanceSliceCount), int(a.MemorySizeMB), true
}
+57 -8
View File
@@ -40,8 +40,8 @@ const mandatorySMIFields = 10
//
// This is the cgo-free path (plan §3.2): the NVML bindings need cgo, which
// would force the whole binary — server included — to be built with a C
// toolchain. nvidia-smi gives everything except NVLink topology, which the
// NVML build adds.
// toolchain. The query gives everything except topology, which Discover
// reads from `nvidia-smi topo -m` afterwards.
func DiscoverGPUsSMI(ctx context.Context, binary string) ([]GPU, error) {
if binary == "" {
// Not configured and not installed is a node without an NVIDIA card,
@@ -60,7 +60,34 @@ func DiscoverGPUsSMI(ctx context.Context, binary string) ([]GPU, error) {
if err != nil {
return nil, fmt.Errorf("running %s: %w", binary, err)
}
return parseSMI(string(out))
cards, err := parseSMI(string(out))
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
// the report is the slowest thing nvidia-smi does.
if !anyMIGCapable(cards) {
return cards, nil
}
listed, report := readMIG(ctx, binary)
return expandMIG(cards, listed, report), nil
}
// anyMIGCapable reports whether any card could be in MIG mode: MIG began
// with compute capability 8.0 (the A100). The consumer cards of the same
// generations have none, but telling them apart by name is guesswork, and
// asking one costs only two more nvidia-smi runs at registration.
func anyMIGCapable(cards []GPU) bool {
for _, c := range cards {
major, _, _ := strings.Cut(c.ComputeCapability, ".")
if n, err := strconv.Atoi(major); err == nil && n >= 8 {
return true
}
}
return false
}
func parseSMI(out string) ([]GPU, error) {
@@ -106,9 +133,27 @@ func parseSMI(out string) ([]GPU, error) {
return gpus, nil
}
// residentMemoryToleranceMB is how much allocated memory is written off as
// ForeignMemoryToleranceMB is how much allocated memory is written off as
// driver overhead rather than a foreign process.
const residentMemoryToleranceMB = 512
const ForeignMemoryToleranceMB = 512
// foreignMemorySuffix ends the problem a card with another tenant on it is
// kept out of placement for. One phrase, so that the control plane can find
// it again and drop it when the tenant leaves.
const foreignMemorySuffix = " MiB already allocated by another process"
// ForeignMemoryProblem is the problem for mb of memory held by something
// that is not one of this agent's engines, or "" when that is too little to
// count.
func ForeignMemoryProblem(mb int) string {
if mb <= ForeignMemoryToleranceMB {
return ""
}
return fmt.Sprintf("%d%s", mb, foreignMemorySuffix)
}
// IsForeignMemoryProblem reports whether a problem is ForeignMemoryProblem's.
func IsForeignMemoryProblem(p string) bool { return strings.HasSuffix(p, foreignMemorySuffix) }
// assessHealth flags conditions that should keep a GPU out of placement.
func assessHealth(g *GPU) {
@@ -119,8 +164,12 @@ func assessHealth(g *GPU) {
// Memory already in use means something else is on the card. Starting an
// engine there would either fail to allocate or quietly evict the other
// process's throughput.
if g.MemoryUsedMB > residentMemoryToleranceMB {
g.Health.Problems = append(g.Health.Problems,
fmt.Sprintf("%d MiB already allocated by another process", g.MemoryUsedMB))
//
// Only a first reading: this runs before the agent knows which processes
// are its own, so an engine still running from before a restart reads as
// foreign here. Every heartbeat measures it again, without the agent's
// own engines, and the control plane replaces this with that.
if p := ForeignMemoryProblem(g.MemoryUsedMB); p != "" {
g.Health.Problems = append(g.Health.Problems, p)
}
}
+280
View File
@@ -0,0 +1,280 @@
package discovery
import (
"context"
"encoding/xml"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
"time"
)
// GPUWear is what wears a card out or takes it down, beyond temperature:
// read every minute, from nvidia-smi and the kernel's log. The early signs of
// a card failing are here — a growing count of rows its memory had to remap,
// PCIe link retries, XID errors — long before a replica on it fails.
//
// A count of -1 is not reported: a card without row remapping (before
// Ampere) has no such count, which is not the same as none.
type GPUWear struct {
Index int
// PCIeReplays are link-level retries on the card's PCIe link: a few are
// noise, a count that keeps climbing is a riser, slot or cable.
PCIeReplays int64
// RetiredSBE and RetiredDBE are memory pages retired for single and
// double bit errors (cards before Ampere); RetirePending is a page
// waiting for a reset to be retired.
RetiredSBE, RetiredDBE int64
RetirePending bool
// RemappedCorrectable and RemappedUncorrectable are memory rows remapped
// (Ampere and later). RemapPending waits for a reset; RemapFailure is a
// card out of spare rows — it needs replacing.
RemappedCorrectable, RemappedUncorrectable int64
RemapPending, RemapFailure bool
// NVLink errors summed over the card's links, by kind.
NVLinkCRC, NVLinkReplay, NVLinkRecovery int64
// XIDs are the driver's error events for this card since the agent
// started, and the last of them.
XIDs int64
LastXID int
LastXIDText string
LastXIDAt time.Time
}
// healthEvery is how often health is read: it changes over hours, and the
// kernel log and nvidia-smi's full report are the heavier reads.
const healthEvery = time.Minute
// HealthSampler reads GPUWear, keeping the XID counts between reads.
type HealthSampler struct {
run func(ctx context.Context, name string, args ...string) ([]byte, error)
now func() time.Time
mu sync.Mutex
at time.Time
last []GPUWear
since time.Time
xids map[int]*GPUWear
started bool
}
// NewHealthSampler reads the machine's own nvidia-smi and kernel log.
func NewHealthSampler() *HealthSampler {
return &HealthSampler{now: time.Now, xids: map[int]*GPUWear{},
run: func(ctx context.Context, name string, args ...string) ([]byte, error) {
return exec.CommandContext(ctx, name, args...).Output() //nolint:gosec // fixed tools
}}
}
// Sample is the cards' health, read again once healthEvery has passed. Nil
// on a machine without nvidia-smi.
func (h *HealthSampler) Sample(ctx context.Context) []GPUWear {
h.mu.Lock()
defer h.mu.Unlock()
now := h.now()
if !h.at.IsZero() && now.Sub(h.at) < healthEvery {
return h.last
}
h.at = now
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
report, err := h.run(ctx, "nvidia-smi", "-q", "-x")
if err != nil {
h.last = nil
return nil
}
cards, bus := parseHealthReport(report)
if out, err := h.run(ctx, "nvidia-smi", "nvlink", "-e"); err == nil {
for i, e := range parseNVLinkErrors(string(out)) {
if i < len(cards) {
cards[i].NVLinkCRC, cards[i].NVLinkReplay, cards[i].NVLinkRecovery = e[0], e[1], e[2]
}
}
}
h.readXIDs(ctx, now, bus)
for i := range cards {
if x := h.xids[i]; x != nil {
cards[i].XIDs, cards[i].LastXID, cards[i].LastXIDText, cards[i].LastXIDAt = x.XIDs, x.LastXID, x.LastXIDText, x.LastXIDAt
}
}
h.last = cards
return cards
}
// readXIDs adds the XID events logged since the last read. The first read
// starts from now: what happened before the agent started was before, and
// counting the whole log would raise every alert on every restart.
func (h *HealthSampler) readXIDs(ctx context.Context, now time.Time, bus map[string]int) {
if !h.started {
h.started, h.since = true, now
return
}
since := h.since
h.since = now
out, err := h.run(ctx, "journalctl", "-k", "--no-pager", "-o", "short-unix",
"--since", "@"+strconv.FormatInt(since.Unix(), 10), "-g", "NVRM: Xid")
if err != nil {
// No journal, or nothing matched (journalctl exits 1 for that).
return
}
for _, e := range parseXIDs(string(out)) {
i, ok := bus[e.bus]
if !ok {
continue
}
x := h.xids[i]
if x == nil {
x = &GPUWear{Index: i}
h.xids[i] = x
}
x.XIDs++
x.LastXID, x.LastXIDText = e.code, e.text
x.LastXIDAt = now
if e.at > 0 {
x.LastXIDAt = time.Unix(int64(e.at), 0)
}
}
}
// healthLog is the part of nvidia-smi's XML report health is read from.
type healthLog struct {
GPUs []struct {
ID string `xml:"id,attr"`
PCI struct {
Replay string `xml:"replay_counter"`
} `xml:"pci"`
Retired struct {
SBE string `xml:"multiple_single_bit_retirement>retired_count"`
DBE string `xml:"double_bit_retirement>retired_count"`
Pending string `xml:"pending_retirement"`
// Older drivers name it pending_blacklist.
Blacklist string `xml:"pending_blacklist"`
} `xml:"retired_pages"`
Remapped struct {
Corr string `xml:"remapped_row_corr"`
Unc string `xml:"remapped_row_unc"`
Pending string `xml:"remapped_row_pending"`
Failure string `xml:"remapped_row_failure"`
} `xml:"remapped_rows"`
} `xml:"gpu"`
}
// parseHealthReport reads each card's health, in index order, and maps each
// card's PCI bus ("3b:00", as the kernel names it) to its index.
func parseHealthReport(report []byte) ([]GPUWear, map[string]int) {
var log healthLog
if err := xml.Unmarshal(report, &log); err != nil {
return nil, nil
}
bus := map[string]int{}
out := make([]GPUWear, 0, len(log.GPUs))
for i, g := range log.GPUs {
bus[busOf(g.ID)] = i
out = append(out, GPUWear{
Index: i,
PCIeReplays: count(g.PCI.Replay),
RetiredSBE: count(g.Retired.SBE),
RetiredDBE: count(g.Retired.DBE),
RetirePending: yes(g.Retired.Pending) || yes(g.Retired.Blacklist),
RemappedCorrectable: count(g.Remapped.Corr),
RemappedUncorrectable: count(g.Remapped.Unc),
RemapPending: yes(g.Remapped.Pending),
RemapFailure: yes(g.Remapped.Failure),
})
}
return out, bus
}
// busOf is a PCI address as the kernel's XID lines carry it: nvidia-smi
// says "00000000:3B:00.0", the kernel "0000:3b:00".
func busOf(id string) string {
id = strings.ToLower(strings.TrimSpace(id))
if i := strings.LastIndex(id, "."); i >= 0 {
id = id[:i]
}
parts := strings.Split(id, ":")
if len(parts) >= 2 {
return parts[len(parts)-2] + ":" + parts[len(parts)-1]
}
return id
}
func count(s string) int64 {
n, err := strconv.ParseInt(strings.TrimSpace(s), 10, 64)
if err != nil {
return -1
}
return n
}
func yes(s string) bool { return strings.EqualFold(strings.TrimSpace(s), "yes") }
// nvlinkError matches a line of `nvidia-smi nvlink -e`, whose wording varies
// by driver: "Link 0: Replay Errors: 0", "Link 0: CRC Errors: 0",
// "Link 3: Data CRC Errors: 2".
var (
nvlinkGPU = regexp.MustCompile(`^GPU (\d+):`)
nvlinkError = regexp.MustCompile(`Link \d+:\s*(.*?)\s*Errors?\s*:\s*(\d+)`)
)
// parseNVLinkErrors sums each card's link errors by kind: CRC, replay,
// recovery.
func parseNVLinkErrors(out string) map[int][3]int64 {
res := map[int][3]int64{}
gpu := -1
for _, line := range strings.Split(out, "\n") {
if m := nvlinkGPU.FindStringSubmatch(strings.TrimSpace(line)); m != nil {
gpu, _ = strconv.Atoi(m[1])
continue
}
m := nvlinkError.FindStringSubmatch(line)
if m == nil || gpu < 0 {
continue
}
n, _ := strconv.ParseInt(m[2], 10, 64)
e := res[gpu]
kind := strings.ToLower(m[1])
switch {
case strings.Contains(kind, "crc"):
e[0] += n
case strings.Contains(kind, "replay"):
e[1] += n
case strings.Contains(kind, "recovery"):
e[2] += n
}
res[gpu] = e
}
return res
}
type xidEvent struct {
bus string
code int
text string
at float64
}
// xidLine matches the driver's report of an XID:
// "NVRM: Xid (PCI:0000:3b:00): 79, pid=..., GPU has fallen off the bus."
var xidLine = regexp.MustCompile(`NVRM: Xid \(PCI:([0-9a-fA-F:.]+)\): (\d+),\s*(.*)$`)
// parseXIDs reads journalctl's short-unix lines: a timestamp, then the
// message.
func parseXIDs(out string) []xidEvent {
var evs []xidEvent
for _, line := range strings.Split(out, "\n") {
m := xidLine.FindStringSubmatch(line)
if m == nil {
continue
}
code, _ := strconv.Atoi(m[2])
e := xidEvent{bus: busOf(m[1]), code: code, text: strings.TrimSpace(m[3])}
if f := strings.Fields(line); len(f) > 0 {
e.at, _ = strconv.ParseFloat(f[0], 64)
}
evs = append(evs, e)
}
return evs
}
Loaded 100 of 1301 files, more files were not shown because too many files have changed in this diff. Show more