Compare commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Retires appendGuardIfMissing: the import does it.

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

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

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

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

No files matched your search

+8
View File
@@ -39,6 +39,14 @@ jobs:
- 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
+16
View File
@@ -55,6 +55,21 @@ jobs:
(cd dist && sha256sum "${name}" >> checksums.txt)
done
# llmgw-kb, which people run on their own machines to put their
# documents into a knowledge base: Windows too, which llmgw itself
# does not build for.
for target in windows/amd64 windows/arm64 darwin/amd64 darwin/arm64 linux/amd64 linux/arm64; do
os="${target%/*}"; arch="${target#*/}"
ext=""; [ "$os" = windows ] && ext=".exe"
name="llmgw-kb-${version}-${os}-${arch}${ext}"
echo "building $name"
CGO_ENABLED=0 GOOS="$os" GOARCH="$arch" go build \
-trimpath \
-ldflags "-s -w -X gitea.mynodes.xyz/ilker/llmgw/internal/version.Version=${version}" \
-o "dist/${name}" ./cmd/llmgw-kb
(cd dist && sha256sum "${name}" >> checksums.txt)
done
# The reverse proxy configurations, so a release page carries
# everything install.md asks for and the repository is not needed.
for f in deploy/proxy/Caddyfile deploy/proxy/nginx.conf; do
@@ -91,6 +106,7 @@ jobs:
# test the cross-compiled ones, which is why the native build is
# checked rather than assumed.
./dist/llmgw-${version}-linux-amd64 --version
./dist/llmgw-kb-${version}-linux-amd64 --version
cat dist/checksums.txt
# Signed, so a machine that trusts the key built into it installs
+255
View File
@@ -761,6 +761,261 @@ portal is not done — the page shows; the console and ctl change.
---
## E. Every setting takes effect when it is changed (2026-10-05)
Asked for after a site visit: an upgrade left server.yaml without guardrails,
a node on another subnet stayed lost until firewall_networks was edited by
hand, and nothing in server.yaml, agent.yaml or a generated unit applies until
somebody restarts the right service on every machine. The rule from here on:
**a file holds only what one machine needs to start; everything else is the
cluster's, kept in the store, changed through ctl, the console or the web,
audited, and in force on every control plane within seconds.**
### E1. Cluster settings in the store (L) — done 2026-10-05
Live on the PoC (140, 150): imported as version 1; a guard change applied on
both without a restart; a router change restarted the standby at once and the
leader 30s later with /health answering throughout; rollback did the same.
- Table `cluster_settings` (versioned YAML document, who and why). The
cluster-wide part of server.yaml lives there; server.yaml keeps the
per-machine keys only (listeners, TLS paths, store DSN, secrets, tracing,
monitoring, mirror.api/tls, backup_dir/key, endpoints).
- First start against a store with no document imports the file's cluster
keys (and 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, then applied: `guard` live, the rest by a
graceful restart of the process (standbys first, the leader last), which
systemd brings back. No "restart now?" anywhere.
- `llmgw ctl config show|history|get|set|unset|edit|rollback`; the console's
settings screen edits the store for cluster keys; admin API
`GET/PUT /api/v1/settings`, `/history`, `/rollback`.
- A standby that becomes leader re-reads the catalog, engine choices and
facility state from the store (it kept what it read at start).
- Retires `appendGuardIfMissing` (server init): the import does it.
### E2. Generated files from settings (L) — firewall on/off done 2026-10-06
`firewall.enabled` in the cluster's settings reaches nodes over the agent
stream and control planes through llmgw-firewall-sync.path. Left: the mirror
machine and store members, the allowed set from membership, the other files.
Every machine runs the agent (root) with roles — node, control plane, mirror,
store member — and the agent renders and applies what was written once by
init: the llmgw nft table, the mirror's unit flags, keepalived, etcd and
Patroni configuration. The firewall's allowed set is the cluster's membership
(every node, control plane, mirror and store member) plus
`firewall.extra` from the store; 7443 stays open (mTLS). A node added or
removed changes every machine's rules. Retires `firewall.Admit` and
`firewall_networks` in server.yaml (imported into the store).
### E3. agent.yaml down to bootstrap (M) — done 2026-10-08 (branch `settings-e`)
agent.yaml keeps the control planes' addresses, its certificate and its id.
Labels, fabrics, engines, heartbeat and supervisor settings move to the
store per node and reach the agent over its stream; a change to the file is
noticed and the agent restarts itself.
Built as a section of the cluster's settings rather than a store of its own,
so history, audit, `ctl config`, the console and the portal page cover it:
`agents.default` and `agents.nodes.<id>` (config.AgentSettings; bootstrap
keys node_id/server(s)/hostname/mgmt_addr/tls refused; each node's merge
loaded as an agent would, so a bad fabric is refused on save). A live key:
the control plane sends ControlMessage.AgentSettings on register and, a
second apart per node, on change; the agent compares with what it started on,
checks it loads beside its agent.yaml (else logs and keeps running), keeps it
in /var/lib/llmgw-agent/agent-settings.yaml and restarts (engines adopted).
At start: agent.yaml < kept settings < LLMGW_ env; kept settings that no
longer load are set aside, never a reason not to start. agent.yaml is
watched every 10 s and restarted on when it changes and loads. Left: file
keys are not imported into the settings (they keep working under them);
`node add` still writes a full agent.yaml; labels have two homes now
(`ctl label`, which wins, and agents.*.labels).
### E4. Web settings page (S) — done 2026-10-08 (branch `settings-e`)
The same document, edited on /admin/settings, for whoever has no terminal.
Built: administrators only (staff roles get 404: webhook secrets), checked by
the same function as PUT /api/v1/settings, made from a base version or
refused on a newer one (the edit kept in the editor), history with view and
roll back as a new version, audit with the size not the body, English and
Turkish, a Settings link in the menu.
---
## F. Company documents: knowledge bases (RAG), then adapters (2026-10-08)
A company wants a model to answer from its own documents. Retrieval is the
tool for *knowledge* (sources shown, re-indexed in minutes, access per
tenant); LoRA training is for *behaviour* (vocabulary, format) and has no
access control — whatever goes into weights reaches everyone who may use the
adapter. RAG first. Decisions taken: access per collection (no per-document
ACL yet); a personal key or session names a project or team and gets that
chain only, never the union of its person's memberships; PostgreSQL store
only, off on SQLite with a sentence saying why.
### F1. Store: collections, grants, chunks (M) — done 2026-10-08 (branch `rag`)
Built (`internal/store/knowledge.go`, migration 0035 PostgreSQL only):
- `kb_collections` (owner scope/id, org, embed_model, dims), `kb_grants`
(read|write, any scope, never across organizations; a key needs a
project; a person needs a membership in the organization),
`kb_documents` (digest: the same again is no change, a new one replaces
in one transaction).
- `kb_chunks` is created when the store opens, not in a migration (a
migration needing an extension would stop the control plane; one that
skipped it would never run again): partitioned by organization (hashed
partition name), `tsv` generated (`simple`), GIN on the parent, one
partial HNSW index per collection on its organization's partition
(`embedding::vector(dims)`, `WHERE collection = '<id>'`). EXPLAIN on
20k rows: partition pruned, that index used, policy applied.
- RLS FORCEd on `kb_chunks` against `llmgw.kb_org`/`llmgw.kb_collections`
(set with set_config per transaction), one role — the table owner — not
a separate one. Chunks are deleted by the foreign-key cascade from their
document, which RLS does not block.
- `CollectionsFor(Effective, write)`: owner or grant anywhere on the chain;
no organization (personal key without a project) reads nothing.
- `SearchChunks`: per collection nearest-neighbour (literal id and cast so
the partial index is used) plus a full-text arm with any word
(`a | b`, lowercased by the database, not Go), fused by reciprocal rank.
- Deleting a project/team/org that owns collections is refused like one
with members (changed from "deleted with them": every other delete in the
tree refuses); DeleteUser/ForgetUser delete the person's collections;
deleting an organization drops its partition; grants to a deleted scope go.
- `server init` installs `postgresql-N-pgvector` / `pgvector_N` and enables
it; pgha installs it on every member at setup and enables it on the
leader on every run. Neither stops on failure.
Left: a cluster set up before (the PoC) gets no package from a rerun of
pgha on a running cluster — install by hand or add an upgrade step;
`server restore`/`CopyTo` do not carry kb tables (SQLite → PostgreSQL only,
and SQLite has none); tests need `LLMGW_TEST_POSTGRES` pointing at a
pgvector database as a non-superuser (a superuser bypasses RLS).
### F2. Ingest on the user's machine (L) — done 2026-10-08 (branch `rag`)
Uploading whole files to the control plane was dropped: a PDF is 20–100×
its text, and the work belongs where the files are. Instead a small client,
`llmgw-kb`, run by the person on their own machine with their own key:
- **Its own binary** (`cmd/llmgw-kb`), for Windows, macOS and Linux on amd64
and arm64. `llmgw` itself does not build on Windows (agent syscalls), and
employees' laptops should not carry the server, agent and setup screens. A
test keeps server packages out of its imports.
- `llmgw-kb sync <collection> <dir|file…> [--include/--exclude globs]
[--dry-run] [--delete]`: scan → local catalog (path, size, mtime, sha256;
cache under os.UserCacheDir, digests not recomputed for unchanged files)
→ the collection's manifest from the gateway (name → digest only) → diff
→ for new/changed files only: extract, chunk, embed through the gateway's
`/v1/embeddings` with the person's key (its accounting, limits, guard and
band apply), and send name, digest, chunk texts and vectors; `--delete`
removes documents whose files are gone. `--dry-run` lists what would leave
the machine and how much. A file that cannot be read (locked, unsupported,
no text) is skipped with the reason; the run goes on.
- **Formats, as many as possible.** Built in, pure Go: PDF (text layer),
DOCX/XLSX/PPTX, ODT/ODS/ODP, RTF, EPUB, HTML/XHTML, EML (text and HTML
parts), Jupyter notebooks, subtitles (SRT/VTT), CSV/TSV, JSON/YAML/XML,
Markdown/RST/AsciiDoc/Org, plain text and source code; ZIP archives
recursed into. With a helper found on the machine: legacy DOC/XLS/PPT, RTF
and anything else LibreOffice opens (`soffice --headless --convert-to`),
images and scanned PDFs through Tesseract (`tur+eng`). Text in UTF-8,
UTF-16 (BOM) or Windows-1254/1252 is converted to UTF-8.
- **Across systems:** document names relative to the synced root, `/`
separated, Unicode NFC (macOS hands out NFD), a warning for two names that
differ only in case; CRLF → LF; key from `LLMGW_API_KEY` or `llmgw-kb
login` (os.UserConfigDir, 0600 where that means something); the system's
CA store, `--ca` for another.
- **Gateway endpoints** (client listener, person's key):
`GET /v1/kb/collections` (what this key may read/write),
`GET /v1/kb/collections/{id}/manifest`, `PUT
/v1/kb/collections/{id}/documents` (one document per request: name,
digest, bytes, chunks with text and embedding; dims checked, chunk count
and size bounded), `DELETE …/documents?name=`. Write needs a write grant
or ownership on the key's chain (`CollectionsFor(…, write)`); otherwise
404.
- **Administration** stays with operators: `llmgw ctl kb
create|list|delete|grant|revoke|docs`, admin API `/api/v1/kb`, audit,
console screen; `docs/knowledge.md`.
- Trade-off accepted: vectors come from the client, so a writer could store
vectors that do not match their text — only in collections they may
already write.
Built: `internal/kb/extract` (formats above; helpers found on PATH and in
each system's install places), `internal/kb/chunk` (≈1800 characters under
the heading path, a paragraph of overlap, sections kept apart once a chunk
is a third full), `internal/kb/kbsync` (ledger in SQLite at
os.UserConfigDir/llmgw-kb/ledger.db: documents keyed by gateway, collection
and name with file, size, mtime, digest, state, reason; runs), `cmd/llmgw-kb`
(login, collections, sync, status, history, formats), `internal/router/kb.go`
(the four gateway endpoints, `X-Llmgw-Project` for personal keys, audit),
`internal/control/adminapi/kb.go` + `llmgw ctl kb` + console Knowledge
screen, `docs/knowledge.md`; CI builds the client for all six targets and
releases attach it. Tested: every format from generated fixtures and a
Turkish PDF (OCR path with Tesseract here), sync against a fake gateway and
end to end against the router on pgvector.
Tried on the PoC 2026-10-08 (control planes 140 and 150 on a dev build of
this branch; their previous binary is /usr/local/bin/llmgw.pre-rag): Ubuntu's
pgvector 0.6.0 installed on 149/150 and enabled; bge-m3's weights were never
in the mirror (fetched from CompendiumLabs/bge-m3-gguf@1864f47f) and it is
held at 1 replica; tree Demo/ik/belgeler and Demo/satis/crm with a key each;
collection Demo/politikalar filled from a Mac through an SSH tunnel with ten
Turkish files in nine formats (9 sent in 5 s, the .doc skipped for want of
LibreOffice; a rerun sent nothing in 0 s; a changed file alone was sent).
Nine Turkish questions each found their page first once search scored by
similarity plus word coverage (rank fusion had got two wrong). The other
team's key saw no collection and got 404 by id; with a read grant it read
and could not write; revoked, nothing again. On the live store the llmgw
role (not superuser, no BYPASSRLS) sees 0 chunks with no scope set and 0
with the organization alone.
Left: the gateway's collection list shows the owner as team:ID, not its
path; no LibreOffice on the dev machine, so the legacy
Office path is untested beyond its error; the client is unsigned (Gatekeeper
and SmartScreen warn); Outlook `.msg` is not read; one document over ~60 MiB
of JSON (a few thousand pages) is refused rather than split.
PostgreSQL tests of store, router and adminapi share LLMGW_TEST_POSTGRES and
the store's TRUNCATE each test; run them with `go test -p 1` or one package
at a time, or a router test loses its rows mid-run.
### F3. Retrieval on the request path (M) — done 2026-10-08 (branch `rag`)
- `llmgw.knowledge: [collections]` in the body (stripped before the
engine), or default collections per project/key.
- Allowed set from the caller's chain (§7.9.2), cached like limits; grants
add up down the tree. Personal keys and sessions need `x-llmgw-project`
(or the playground's picker) naming a scope the person is a member of.
- A collection not allowed answers 404, exactly as a missing one.
- Hybrid search with the filter inside the query (per collection, its own
partial index; pgvector 0.6 is enough —
scored by similarity plus the share of the question's words held (built
in F1/F2 as SearchChunks), `/v1/rerank` when a reranker is in the catalog. Top
chunks into a system message with document and position;
`x-llmgw-sources` lists them.
- Leaks closed: answer-cache key includes the allowed set; per-organization
`cache_salt` on RAG requests; injected passages under `no_store` and the
guard's prompt-injection check; returned doc ids in the audit.
- Done: router tests that tenant B never receives tenant A's chunks
(including via cache), that a personal key without a project is refused,
metrics (retrieval latency, chunks returned), docs.
Built (`internal/router/rag.go`): `X-Llmgw-Knowledge` (header, as the
other llmgw extensions are, not a body field), `X-Llmgw-Knowledge-K`;
collections from the key's chain (kbEffective, shared with the kb
endpoints), 404 for one not readable; the question embedded through the
ordinary path on the caller's principal; SearchChunks; passages trimmed to
the model's context; added to the one leading system message (or a new
one); `x-llmgw-sources`; before guardInput; never cached; metrics. Default collections built (kb_defaults on org/team/project/key, narrowest
wins, 30 s cache, `ctl kb default`, console action; never refuse); the
portal chat's Documents picker (collection, default or none; sources listed
under the answer; a header the page sends itself is dropped). `cache_salt` per organization on requests given passages, sent to vLLM
replicas only (chosen replica's engine). Checked on the PoC 2026-10-08:
vLLM 0.30.0 (cuda01) with TinyLlama-1.1B AWQ run by hand beside the 35B in
the card's last 2.3 GB — a 668-token prompt reused 656 tokens under the same
salt and 0 under another's, with identical text (llmgw cannot place it
there: placement takes whole GPUs). Live on the PoC: a team default answers
a request without the header, `none` turns it off, an explicit header with
k=2 gives two sources and no more, llmgw-kb lists owners by path. Left: returned passages
are not audited per request (the header and the kept request record them).
### F4. Adapter training (L, after F1–F3)
A run-to-completion job placed by the control plane like a replica and
supervised by the agent like an engine, in a Python environment the agent
installs (TRL + PEFT, QLoRA). Data: Q/A pairs generated from a collection by
a cluster model through `/v1/batches`, reviewed by people. Output to the
mirror, into the model's `adapters`, through canary and a quality suite.
Runs when the scaling schedule frees a GPU; 8–14B dense bases on one card.
Never train on a collection some users of the adapter may not read.
---
## D. Facts worth knowing before starting
- Admin API: `127.0.0.1:9090`, no auth, `ctlClient` in `internal/cli/ctl.go`.
+300
View File
@@ -16,11 +16,311 @@ is only released once its **exit criterion** is met, not when its code lands.
| 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 |
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
APIs change between minors without a deprecation period.
## Unreleased
### Added
- **The cluster's settings on the portal** (BACKLOG E4): `/admin/settings`
for administrators. It edits the document `llmgw ctl config edit` opens:
checked as a control plane would load it, refused when made from a
version since replaced, with the history to view and roll back. Changes
are audited by size, never by contents.
- **The nodes' agent settings in the cluster's settings** (BACKLOG E3):
`agents.default` and `agents.nodes.<id>` hold everything agent.yaml can
say but the agent's id, control planes, address and certificate. They are
sent to the agents as they change; an agent starts again on new settings,
or on an edited agent.yaml, with its engines serving throughout. Settings
that would not load are refused on save, and set aside on a node rather
than keep its agent from starting. Nodes need this release to follow
them.
## v0.14.0 — 2026-10-08
Knowledge bases: a company's documents, put in by people from their own
machines, kept apart per tenant, and answered from.
**Upgrading.**
- **pgvector on a cluster set up before this release.** Knowledge bases
need pgvector, which `server init` and the high-availability database
setup now install, but a rerun on a running cluster does not:
1. Install `postgresql-<version>-pgvector` on every database machine.
2. As postgres, run `CREATE EXTENSION vector` in llmgw's database.
3. Restart the control planes.
Until then everything else works, and `llmgw ctl kb list` says why
knowledge bases are off. On a SQLite store they stay off.
- **An embedding model.** A collection needs a catalog model with
`capabilities.embeddings`, for example bge-m3, whose weights are in the
mirror.
- **The client.** People download `llmgw-kb` for their system from this
release.
### Added
- **Collections and who may read them.** A collection is owned by an
organization, team, project or person. Grants add up down the tenancy
tree and never cross an organization. A collection a key may not read is
answered as one that does not exist. Chunks are kept in PostgreSQL with
pgvector, partitioned by organization, with an HNSW index per collection,
and row-level security on the table as a second line behind every
query's own filter. A project, team or organization that owns collections
is not deleted; a person's collections go with their account.
- **`llmgw-kb`**, its own download for Windows, macOS and Linux, run by
people with their own key. It reads documents where they are and sends
text and vectors, never the files, for what changed only; a SQLite ledger
on the machine remembers what it did.
- It reads PDF, Word, Excel, PowerPoint, OpenDocument, RTF, EPUB, HTML,
MHTML, mail with attachments, notebooks, subtitles, ZIP archives, and
text and source code in any common encoding.
- Legacy Office files are read with LibreOffice, and scanned pages with
Tesseract, when those are installed.
- `--dry-run` says what would leave the machine; `--delete` removes only
what that machine sent.
- **Answers from documents.** A chat request names collections in
`X-Llmgw-Knowledge`, or its part of the tree has a default (`llmgw ctl kb
default`). The best passages, chosen by meaning plus a little for the
question's words, are added to the system message, numbered, and
`x-llmgw-sources` says which were used. This happens before the
guardrails judge the request, and the answer is never served from the
answer cache. On vLLM replicas the organization's `cache_salt` keeps
tenants' prefix caches apart. It works through the Responses and Messages
APIs too.
- **Administration**: `llmgw ctl kb` (create, list, grant, revoke, default,
docs, rm, search, delete), `/api/v1/kb`, the console's Knowledge screen,
and a Documents picker on the portal's chat page.
- Metrics `llmgw_kb_retrievals_total` and `llmgw_kb_retrieval_seconds`;
[docs/knowledge.md](docs/knowledge.md).
## v0.13.0 — 2026-10-08
The whole system stopped, started and restarted from one command, a
guardrails judge that no longer refuses ordinary coding requests, and the
rest of the field report from v0.10.6.
**Upgrading.** A cluster keeps the judge its policies name: one installed
before this release goes on asking Llama Guard 3 1B until `llmgw ctl guard
judge use qwen3guard-0.6b`, which the leader then fetches and serves by
itself, and which replaces the policies' Llama Guard category codes.
### Added
- **A judge on a GPU and one on CPUs together**: a policy's `judge.models`
is a list asked in turn, the first that answers giving the verdict; the
next is asked when one has no replica serving, fails, or misses its
`latency_budget`. `llmgw ctl guard judge use qwen3guard-4b
qwen3guard-0.6b` sets the order for every policy with a judge, and
`guard judge status` says which judge stands in for which. A judge on a
GPU is served as a `standard` model, so it takes a card only from what
may give one up.
- **A judge a policy names is fetched if it is missing**: the leader
fetches the weights of a judge llmgw knows (`qwen3guard-0.6b`, `-4b`,
`-8b`, `llama-guard-3-1b`, `-8b`) into the mirror and serves it, when a
policy names it and the catalog does not have it — and again if it is
removed. `guard judge enable --gpu` serves Qwen3Guard-Gen 4B on the class
with the most free cards.
- **The whole system stops, starts and restarts from one command**: `llmgw
stop` takes every model down and answers every request for one with 503
(`system_stopped`), keeping the control planes, agents and mirror running
and no model's definition changed; `llmgw start` brings every model back.
It is the cluster's setting `serving.stopped`, live on every control plane.
`llmgw restart` restarts every node's agent and every control plane, the
leader last, with the engines serving throughout. `llmgw status` says
whether the system serves. One machine's services are `llmgw service
status|start|stop|restart`.
- **A replica says what it computes on**: `ctl replicas` (ON), the console
and the admin API show `CUDA` with the GPUs its processes hold memory on
and how much, as nvidia-smi sees them, or `CPU` — for every engine, kept by
the agent across its own and the control plane's restarts. llama.cpp's
layer count is added when it says it, which it does only at `-lv 4`.
- **`ctl engine-check llamacpp` computes a token on the GPU**: a small GGUF
(`--model`, or the smallest in the weights cache) is loaded with every layer
on the GPU at `-lv 4` and asked for one token; a build whose CUDA backend
does not load fails, by llama.cpp's own account of its layers. With no
`--bin` it checks the build the agent installed.
- **Engine versions and compute capability on nodes**: `ctl nodes` shows
`llamacpp b10991` and `6000-24g (7.5)`; the admin API gives
`engine_versions` and `compute_capabilities`. 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.
- **`ctl restart --max-surge N` and `--max-unavailable N`**: that many of a
model's replicas restart at once, beside themselves or stopped first.
- **Multi-node readiness apart from the node's**: doctor gives both, with
fabric findings in a section of their own; `node add` says the multi-node
one; `ctl nodes` shows a declared fabric's adapters that are down.
- **A forgotten password is set through a link**: `/forgot` mails one when
the cluster's `mail` settings say how (SMTP, sender, the public
`base_url` it points at), saying the same whether or not the address has an
account; `/reset` sets the password once and closes every session. Without
mail, `llmgw users reset-link` makes the link for an operator to hand over.
### Fixed
- **A replica restarted in place keeps its room**: the replica was stopped
and the pass went on to other models before placing its replacement, so a
model waiting for room took the freed cards. On the reference cluster a
GPU judge waiting for the only card took it from the model being
restarted there. The replacement is now placed in the same step.
- **The judge no longer refuses ordinary coding requests**: Llama Guard 3
1B, the judge `server init` installed, could not tell "continue", "devam
et" or "how do I kill all child processes in bash" (0.51 to 0.92 unsafe)
from harmful requests (0.97 to 1.00), at any quantisation. The judge is
now Qwen3Guard-Gen 0.6B (format `qwen3guard`), which gave the same
ordinary requests 0.15 at the most and the harmful ones 1.00, on CPUs,
in 0.8 GB. Its third verdict, controversial, counts as safe unless the
policy says `controversial: unsafe`. `guard judge use qwen3guard-0.6b`
moves an installed cluster to it, and replaces Llama Guard's category
codes, which Qwen3Guard never names, with the baseline's.
- **A long message is judged rather than refused**: a judge read the whole
last user message, and one with a file pasted into it overflowed the
judge's context, so it gave no verdict and `fail: closed` refused the
request. It now reads the start and the end, about 6000 characters.
- **A replica no longer stays "starting" while it serves after a restart**:
an agent that registered before its adopted engines had answered their
health check never said they had, so the control plane kept them starting
and routed nothing to them — on the reference cluster, after `llmgw
restart`, the only replica of a model. The agent now reports each shard
the registration called not ready once it is.
- **A failover no longer replaces healthy replicas**: a standby that took
over placed at once, before the agents had reconnected and said what runs,
so it started a second copy of replicas it did not know of and then drained
the healthy ones as surplus — on the reference cluster the guardrails'
judge and a model's replica, within twenty seconds of a leader restarting.
Placement now waits after a takeover as it does at start.
### Changed
- **A GPU placement llama.cpp says it cannot compute on is refused** when it
warns that it found no usable GPU, even where nvidia-smi cannot be asked;
where it can, holding no memory on its GPUs refuses it as before.
## v0.12.2 — 2026-10-06
The guardrails turned off and on as a whole, and a judge that refuses
ordinary requests explained: the hazard it saw is told to the client and
kept with the request, and `llmgw ctl guard judge ask` shows what it says of
any text. Nothing to do to upgrade; the store gains a column.
### Added
- **The guardrails turn off and on as a whole**: `llmgw ctl guard off` /
`on`, which is `guard.enabled` in the cluster's settings — in force within
seconds, the policies and their attachments kept. `ctl status`, `ctl guard
list` and the guardrails page say when they are off.
- **`llmgw ctl guard judge ask TEXT`** has the judge read a text and shows
its answer as given and as read — for finding out why ordinary requests
are refused. Nothing of the text is kept, not even in the audit.
### Changed
- **A judge's refusal names the hazard**: the client is told `(judge:
S6,Specialized Advice)` rather than `(judge)`, and the verdict is kept with
the request (`guard_rule`): `ctl request`, the server's log and the
guardrails page show it. Llama Guard 3's codes come with their names,
S9 Indiscriminate Weapons, without a categories list.
## v0.12.1 — 2026-10-06
The guardrails' judge, served and watched without server init: a judge
missing, served as the wrong kind of model, or not answering is said on
`ctl status` and the console, and `llmgw ctl guard judge enable` puts it
right. Nothing to do to upgrade.
### Added
- **`llmgw ctl guard judge status` and `enable`**: each judge model the
policies ask, whether it is in the catalog, as a judge, with replicas
ready and answering — and serving the missing one, Llama Guard 3 1B on
CPUs under the name the policies give it, with `--replace` for a model of
that name that is not a judge. No more `server init` to put a judge back.
Also **Do → Serve the guardrails' judge** in the console.
- **A missing or broken judge is said where it is looked for**: `llmgw ctl
status` and the console's Overview list each judge with a problem — not in
the catalog, not a judge, no replica ready, or failing to answer — and
what that does to requests (refused or unjudged).
### Fixed
- **A Llama Guard file served from the mirror page is the judge**: it was
added as a model clients use, under whatever name the form had, so the
policies never found it and refused everything they judged
(`guard_unavailable`). It is now added as a judge, under the name the
policies ask.
- **`ctl` says so when the API answers with a page**: a command a control
plane does not know — the leader on an older release, through a standby —
failed with "invalid character '<'"; it now says which and why.
## v0.12.0 — 2026-10-06
A minor version, because where the configuration lives changes: everything in
server.yaml that is not one machine's own is now the cluster's settings, kept
in the store, the same on every control plane and in force within seconds of
a change. Nothing needs doing by hand to upgrade. The first control plane to
start on v0.12.0 brings its server.yaml's cluster keys into the store, with
the baseline guard policy when the file had none. From then on, change them
with `llmgw ctl config` or the console's Settings, not in server.yaml, whose
cluster keys are ignored, with a warning. Upgrade every control plane (`llmgw
ctl upgrade` does, the leader last): until the leader runs v0.12.0,
`llmgw ctl config` through a standby is refused. Nodes need v0.12.0 to follow
the firewall switch.
### Changed
- **The cluster's settings live in the store, and every change is in force
within seconds.** Everything in server.yaml that is not one machine's own
(guardrails, limits and prices, routing, engines, notifications, the
mirror's policy, declared models, offline, federation, facility budgets) is
a versioned document in the store, the same on every control plane.
`llmgw ctl config` shows, sets, edits, lists and rolls it back, and the
console's Settings screen edits it. A change is checked, kept with who made
it and why, and applied: guardrails live, anything else by every control
plane starting again on it, standbys first and the leader last. A change to
a machine's own keys in its server.yaml is noticed and applied the same way.
No "restart now?" any more, and no second copy to keep in step. The first
start against a store brings in the file's cluster keys, with the baseline
guard policy when the file had none, so an upgraded cluster gets its
guardrails; after that, those keys in a server.yaml are ignored, with a
warning.
### Added
- **llmgw's firewall can be turned off and on for the whole cluster**:
`llmgw ctl config set firewall.enabled false`. Every node's agent takes its
table out within seconds, and each control plane's new
`llmgw-firewall-sync.path` does the same there; `true` puts the rules back
as they were. `firewall_networks` changes reach the nodes the same way.
- **`llmgw wipe`** removes llmgw from a machine — services, firewall table,
configuration, the authority, secrets, state, users — after listing it and
asking for the machine's name; `--models`, `--store` and `--all` take
weights, the store's database here, backups and the binary too. Also
**Do → Wipe this machine** in the console.
### Fixed
- **The mirror is given an address the nodes reach**: `server init` and
`mirror init` took the machine's first address for the mirror's `--base`,
which on a machine with Docker could be 172.17.0.1 — a node then asked its
own bridge for every model and was refused. They now take the source of
the default route, else the first address not on a container's or a
bridge's interface. And `node add` has the node reach the mirror's files
before it is added: one that cannot fails the checks, naming the address
and what to change.
- **A standby that becomes the leader reads the store again**: models added
or removed, engine builds chosen and domains held through the leader before
it, rather than placing on what it read when it started.
- **A node on another network joins**: `node add` set up the node's firewall
but left the control plane's to the networks it was installed with, so a node
on another subnet was installed and then never heard from (lost). It now lets
the node in on the control plane it runs on — its subnet, or its address
when a router is between them — and says so; a network already let in
changes nothing.
## v0.11.4 — 2026-10-05
Two control planes, run in anger on the reference cluster.
+1
View File
@@ -6,6 +6,7 @@ LDFLAGS := -X '$(MODULE)/internal/version.Version=$(VERSION)'
build:
go build -ldflags "$(LDFLAGS)" -o bin/llmgw ./cmd/llmgw
go build -ldflags "$(LDFLAGS)" -o bin/llmgw-kb ./cmd/llmgw-kb
test:
go test ./...
+63 -33
View File
@@ -3,8 +3,9 @@
A single-binary LLM gateway for clusters with multiple InfiniBand fabrics,
NVLink domains and heterogeneous GPUs.
It serves LLMs running on your own GPU servers (vLLM / SGLang) behind one
OpenAI-compatible endpoint. Design document: [llmgw-plan.md](llmgw-plan.md).
It serves LLMs running on your own GPU and CPU servers (vLLM, SGLang,
llama.cpp, TensorRT-LLM) behind one OpenAI- and Anthropic-compatible
endpoint, and answers from your own documents. Design document: [llmgw-plan.md](llmgw-plan.md).
## Getting started
@@ -37,6 +38,9 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
| v0.9.0 | — | Managing people and the tenancy tree | released |
| v0.10.0 | — | InfiniBand fabrics and NVLink, doctor, node add, engines installed by the nodes | released |
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | released |
| 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 |
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
As of v0.6.0 a cluster runs unattended: agents report their hardware, the
@@ -44,38 +48,60 @@ control plane places replicas — including ones larger than a single machine
models load when someone asks for them and stop when nobody does, and keys,
budgets and rate limits decide who may use any of it.
Since v0.11.0, not yet released (see [CHANGELOG.md](CHANGELOG.md),
"Unreleased"):
Since v0.11.0 (each release's detail is in [CHANGELOG.md](CHANGELOG.md)):
- **Security hardening** — signed releases only, a token on the admin API,
engines not run as root, TLS for the agent port, etcd and Patroni, a
firewall for llmgw's internal ports, encrypted backups, bounded
connections, sign-in throttling, a Content-Security-Policy.
- **Guardrails** — policies that check requests and answers (PII, secrets,
prompt injection, deny patterns, a judge model such as Llama Guard), in
observe or enforce mode, attached to any part of the tenancy tree:
- **Knowledge bases** (v0.14.0). People put a company's documents in from
their own machines with `llmgw-kb`, a separate download for Windows,
macOS and Linux that reads PDF, Office, OpenDocument, mail and many more
formats and sends text and vectors, never the files. Collections are kept
apart per tenant, down to row-level security in PostgreSQL. A chat
request, or a part of the tree by default, is answered from them with
its sources: [docs/knowledge.md](docs/knowledge.md).
- **One command for the whole system** (v0.13.0): `llmgw stop`, `start` and
`restart`. Qwen3Guard is the guardrails' judge, on a GPU and on CPUs
together.
- **Settings that apply when changed** (v0.12.0). The cluster's
configuration lives in the store, the same on every control plane and in
force within seconds (`llmgw ctl config`, the console's Settings). The
firewall can be switched on and off for the whole cluster.
- **Security hardening** (v0.11.2): signed releases only, a token on the
admin API, engines not run as root, TLS for the agent port, etcd and
Patroni, a firewall for llmgw's internal ports, encrypted backups,
bounded connections, sign-in throttling, a Content-Security-Policy.
- **Guardrails** (v0.11.2). Policies check requests and answers for PII,
secrets, prompt injection, deny patterns, and with a judge model; they
observe or enforce, and attach to any part of the tenancy tree:
[docs/guardrails.md](docs/guardrails.md).
- **Administration** — an audit log of every change (`ctl audit`), staff
roles (viewer, operator, billing, admin), single sign-on through OpenID
Connect and provisioning through SCIM 2.0, key lifetime limits and
rotation with overlap (`keys ttl`, `keys rotate`), usage export as CSV
(`ctl usage export`).
- **Notifications** — webhooks, signed or Slack-shaped, for a model parked,
a node lost, a budget spent and new weights published (`notify.webhooks`,
`ctl notify test`), and Alertmanager receiver templates.
- **Traffic and models** — request tags, canaries of new weights with
automatic rollback, quality suites gating updates, a response cache,
kept and replayed requests, batches, Anthropic's Messages API, more of
OpenAI's (structured output, images, rerank, audio, moderations),
scaling by the clock, SLO-aware admission, TensorRT-LLM, MIG, models
imported from Ollama, routing by what each vLLM replica's cache holds,
LoRA adapters loaded on demand, other llmgw clusters as providers, and
placement that knows racks, PDUs and power caps.
- **Operations** — `server restore` and off-machine backup copies, secrets
in Vault, forgetting a person and keeping a project's requests in the
cluster, offline installs from a bundle, a Helm chart, tracing from the
control plane to the engines, the admin API in OpenAPI with a Terraform
provider, and the portal in English and Turkish with its docs at /docs.
- **Administration** (v0.11.2):
- an audit log of every change (`ctl audit`);
- staff roles (viewer, operator, billing, admin);
- single sign-on through OpenID Connect and provisioning through SCIM
2.0;
- key lifetime limits, and rotation with overlap (`keys ttl`, `keys
rotate`);
- usage export as CSV (`ctl usage export`).
- **Notifications** (v0.11.2): webhooks, signed or Slack-shaped, for a
model parked, a node lost, a budget spent and new weights published, and
Alertmanager receiver templates.
- **Traffic and models** (v0.11.2):
- request tags, canaries of new weights with automatic rollback, and
quality suites gating updates;
- a response cache, kept and replayed requests, and batches;
- Anthropic's Messages API, and more of OpenAI's (structured output,
images, rerank, audio, moderations);
- scaling by the clock, and SLO-aware admission;
- TensorRT-LLM, MIG, and models imported from Ollama;
- routing by what each vLLM replica's cache holds, and LoRA adapters
loaded on demand;
- other llmgw clusters as providers, and placement that knows racks,
PDUs and power caps.
- **Operations** (v0.11.2):
- `server restore`, off-machine backup copies, and secrets in Vault;
- forgetting a person, and keeping a project's requests in the cluster;
- offline installs from a bundle, and a Helm chart;
- tracing from the control plane to the engines;
- the admin API in OpenAPI, with a Terraform provider;
- the portal in English and Turkish, with its docs at /docs.
Every one of these is in the console as well as on the command line:
[docs/console.md](docs/console.md).
@@ -124,6 +150,9 @@ Every page is also in the binary: `llmgw docs`, or **Manual** in the console.
official OpenAI and Anthropic SDKs, and llmgw's extensions
- [docs/guardrails.md](docs/guardrails.md) — checking requests and answers:
policies, detectors, a judge model, and where each applies
- [docs/knowledge.md](docs/knowledge.md) — knowledge bases: collections,
who may read them, `llmgw-kb` on people's machines, and answers from
documents
- [docs/choosing-models.md](docs/choosing-models.md) — which models to run,
how many of each, and where the weights should live
@@ -152,7 +181,7 @@ Every page is also in the binary: `llmgw docs`, or **Manual** in the console.
## Building
```sh
make build # bin/llmgw
make build # bin/llmgw and bin/llmgw-kb
make test # unit tests
make race # with the race detector
```
@@ -169,6 +198,7 @@ llmgw ctl cordon gpu-a-01 # stop new placement, leave what runs
sudo llmgw doctor # on a node: what its GPUs, NVLink and fabric need
llmgw ctl audit --since 24h # who changed what
llmgw ctl usage export --from 2026-09-01 --to 2026-10-01 --by project,model
llmgw ctl kb list # knowledge-base collections, grants, defaults
```
Bare `llmgw` opens the console, which does all of this from one screen.
File diff suppressed because it is too large. Load diff
+56
View File
@@ -71,6 +71,9 @@ message ControlMessage {
FabricTestRequest fabric_test = 9;
UpgradeAgent upgrade = 10;
ControlPlanes control_planes = 11;
Firewall firewall = 13;
RestartAgent restart_agent = 14;
AgentSettings agent_settings = 15;
}
// trace is the W3C trace context (traceparent, and tracestate when there
// is one) of the control plane's span that sent this command, so what the
@@ -80,6 +83,38 @@ message ControlMessage {
map<string, string> trace = 12;
}
// RestartAgent has the agent start again, as llmgw restart asks of every
// node: its engines keep serving and are taken over again.
message RestartAgent {
string reason = 1;
}
// Firewall is whether llmgw's firewall is kept on the node — its nftables
// table, which lets the cluster's networks alone reach the engines' ports and
// the node exporter — sent when an agent registers and whenever the
// cluster's settings change it (firewall.enabled).
message Firewall {
bool enabled = 1;
// networks are let in besides those the node reaches the control planes
// on: firewall_networks, networks or single addresses.
repeated string networks = 2;
// engine_ports_from and engine_ports_to are the engines' ports
// (control.port_range).
int32 engine_ports_from = 3;
int32 engine_ports_to = 4;
}
// AgentSettings is what the cluster's settings say of this node's agent
// (agents.default, with agents.nodes.<id> over it), sent when it registers
// and whenever they change. The agent keeps it beside agent.yaml, which
// holds only what it needs to start; over that file, it is what the agent
// runs on. A change from what it runs on has the agent start again on it,
// its engines serving throughout.
message AgentSettings {
// yaml is agent.yaml's keys, as YAML; empty for none.
string yaml = 1;
}
// ControlPlanes is every control plane an agent may dial, sent by the leader
// when an agent registers and whenever the list changes. The agent keeps it
// beside its configuration, so a second control plane added after the node
@@ -250,6 +285,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 {
@@ -589,6 +629,18 @@ message ProvisionStatus {
string detail = 5;
}
// Compute is where an engine runs its model, as it said when it loaded it.
message Compute {
// backend is "CUDA", "Vulkan", "Metal" or "CPU".
string backend = 1;
// devices are the devices it named: "CUDA0: Quadro RTX 6000 (7.5)".
repeated string devices = 2;
// layers_offloaded of layers_total went to the devices; both 0 when the
// engine did not say.
int32 layers_offloaded = 3;
int32 layers_total = 4;
}
// ShardStatus reports a shard's lifecycle transition.
message ShardStatus {
string node_id = 1;
@@ -610,6 +662,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;
+467
View File
@@ -0,0 +1,467 @@
// 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.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)
}
}
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ func (d Doc) Source() string {
// than with either reader, because the console's manual and the gateway's
// docs portal both list them, and two lists drift.
var docOrder = []string{"console", "quick-install", "install", "node-onboarding", "onboarding",
"clients", "guardrails", "choosing-models", "fabrics", "facility-aware-placement", "data",
"clients", "guardrails", "knowledge", "choosing-models", "fabrics", "facility-aware-placement", "data",
"reference", "development"}
// Manual is every document, in reading order: those docOrder names, then any
File diff suppressed because it is too large. Load diff
+26 -8
View File
@@ -164,6 +164,15 @@ which): requests, tokens, errors and spend.
team or project is guaranteed and may at most hold, and what it holds now.
**A** adds one; on a row, change or remove it.
**Knowledge** lists the knowledge-base collections: each one's owner, the
model that embeds it, how many documents and chunks it holds and who else it
is granted to. **A** makes one. On a row: its **Documents**, **Grant…** a
project, team, organization, person or key read or write access, **Revoke**
a grant, **Answer from it by default…** for a part of the tree, **Try a
search…** as an administrator, or **Delete…** it. People
fill collections from their own machines with `llmgw-kb`
([knowledge.md](knowledge.md)). Off on a SQLite store, and says why.
**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
@@ -270,14 +279,20 @@ page's text.
## Settings
**server.yaml** (on a node, **agent.yaml**) is every key of the file, as a
tree of menus built from the configuration reference: the value set, or the
one that applies when nothing is, its type, and what it is for. Enter changes
a value or opens a block; on a list or map of blocks, Enter opens an entry and
**D** removes one. **U** puts a key back to its default. **S** saves: the file
is checked the way the service reads it before anything is written, and its
comments are kept. The service reads it when it starts, so the console offers
to restart it. Another control plane keeps its own copy.
**server.yaml** (on a node, **agent.yaml**) is every key, as a tree of menus
built from the configuration reference: the value set, or the one that
applies when nothing is, its type, and what it is for. Enter changes a value
or opens a block; on a list or map of blocks, Enter opens an entry and **D**
removes one. **U** puts a key back to its default. **S** saves.
On a control plane, the cluster's keys are read from and saved to the
cluster's settings in the store — the same on every control plane, in force
on all of them within seconds (`llmgw ctl config` does the same from a
shell) — and this machine's own keys (listeners, certificates, the store's
address) to its server.yaml, which the server notices and applies by itself.
Either is checked before anything is kept, and the file's comments survive.
On a node, agent.yaml is read when the agent starts, so the console offers
to restart it.
## Do
@@ -313,6 +328,9 @@ to restart it. Another control plane keeps its own copy.
- **Restart all replicas**, one at a time.
- **Back up now** — the store and `/etc/llmgw` into one archive
(`sudo llmgw server backup`).
- **Wipe this machine** — removes llmgw's services, configuration, state and
users from this machine, after listing them and asking for its name
(`sudo llmgw wipe`; on a node too).
- **Leave**.
## The node's console
+131 -19
View File
@@ -4,13 +4,30 @@ Written for the operator deciding what the gateway lets through: what a
policy can check, what it can do, and how one is attached to a team.
`llmgw server init` writes a `baseline` policy that applies to every
request, with Llama Guard 3 1B as its judge on a CPU; see
[install.md](install.md#2-the-control-plane) for what it checks. Without a
`guard:` section in `server.yaml` nothing is checked and nothing costs
anything. With one, every request is judged once it is authorised and
request, with Qwen3Guard-Gen 0.6B as its judge on CPUs; see
[install.md](install.md#2-the-control-plane) for what it checks. The
policies are part of the cluster's settings, kept in the store: `llmgw ctl
config edit` (or the console's Settings) changes them, and the change is in
force on every control plane within seconds, without a restart. A cluster
whose settings were brought in from a server.yaml without a `guard:` section
gets the `baseline` policy, without a judge. With no policies, nothing is
checked and nothing costs anything. With one, every request is judged once it is authorised and
within its limits, with its stored conversation spliced in, and before it is
sent to a model; and the answer is judged before the client sees it.
## Turning them off and on
```
llmgw ctl guard off --note "why" # no request is checked, whatever the policies say
llmgw ctl guard on # every policy checks again, as it was
```
That is `guard.enabled` in the cluster's settings, in force on every control
plane within seconds without a restart. The policies and where they are
attached are kept. While off, `llmgw ctl status`, `llmgw ctl guard list` and
the guardrails page say so. To stop one policy rather than all, give it
`mode: off`, or `mode: shadow` to have it record what it would do.
## A policy
```yaml
@@ -97,44 +114,136 @@ guard:
## A judge model
Patterns find what has a shape. For what does not — violence, self-harm,
abuse, whatever a moderation model was trained on — a policy can have a
**judge model on the cluster** read the text:
Patterns find what has a shape. For what does not — violence, crime,
sexual content, self-harm, jailbreaks, whatever a moderation model was
trained on — a policy can have a **judge model on the cluster** read the
text.
The judge must be in the catalog under the name the policy gives, as a
judge (`role: guard`), with a replica ready. When it is not, what the
policy judges is refused with `guard_unavailable` (`fail: closed`) or served
unjudged (`fail: open`), and `llmgw ctl status` and the console's Overview
say which judge and why.
### Which judge
| judge | runs on | size | |
|---|---|---|---|
| `qwen3guard-0.6b` | CPUs | 0.8 GB | the default: about 2 s a check on two threads |
| `qwen3guard-4b` | a GPU | 2.5 GB | for a cluster with a card to spare |
| `qwen3guard-8b` | a GPU | 4.7 GB | |
| `llama-guard-3-1b` | CPUs | 0.9 GB | the earlier default: refuses ordinary coding requests |
| `llama-guard-3-8b` | a GPU | 4.6 GB | |
Measured on the reference cluster, as the judge's probability of "unsafe"
for ordinary coding requests against harmful ones, Llama Guard 3 1B could
not tell them apart: "continue" 0.51, "continue the code above" 0.89, "how
do I kill all child processes in bash" 0.92, against 0.97 to 1.00 for
harmful requests, at any quantisation. Qwen3Guard-Gen 0.6B gave the
ordinary ones 0.15 at the most and the harmful ones 1.00. Its verdicts are
safe, unsafe and **controversial**; controversial counts as safe unless the
policy says `controversial: unsafe`.
A policy that names one of these is enough: the leader fetches its weights
into the mirror and serves it as a judge, and serves it again if it is
removed from the catalog. Another judge is served by hand with `--repo`.
```
llmgw ctl guard judge status # each judge the policies ask, and what is wrong with it
llmgw ctl guard judge enable # Qwen3Guard-Gen 0.6B on CPUs, or the judge the policies ask that is missing
llmgw ctl guard judge enable --gpu # Qwen3Guard-Gen 4B on a GPU
llmgw ctl guard judge use qwen3guard-4b qwen3guard-0.6b # the order every policy asks them in
llmgw ctl guard judge enable --replace # when a model of that name is there, but not as a judge
```
or **Do → Serve the guardrails' judge** in the console.
### A GPU and CPUs together
`judge.models` is a list asked in turn: the first judge that answers gives
the verdict. The next is asked when one has no replica serving, fails, or
does not answer within `latency_budget`; a judge with no replica at all is
passed over at once. A judge on a GPU first and one on CPUs after it is
the GPU's speed while it has its card, and the CPUs' when it does not.
```yaml
judge:
models: [qwen3guard-4b, qwen3guard-0.6b]
input: block
output: block
latency_budget: 5s # each judge's wait
```
A judge on a GPU is served as a `standard` model, on one card: it takes a
card only from what may give one up, and waits while every card is busy,
the judge on CPUs standing in. A judge on CPUs is `critical`, with two
replicas, because every judged request waits on one. `llmgw ctl guard judge
status` says, for each judge, which is asked when it gives no verdict.
### When it refuses ordinary requests
A refusal by a judge names the hazard it saw — the client is told `refused
by the gateway's policy (judge: Violent)` — and the same is kept with the
request: `llmgw ctl request <id>`, the server's log (`guard_rule`) and the
guardrails page show it. To see what the judge says of any text, without
anything being kept:
```
llmgw ctl guard judge ask 'How do I sort a list in Python?'
# qwen3guard-0.6b: safe, 1900 ms
# it answered: "Safety: Safe\nCategories: None"
```
A judge that says `unsafe` to ordinary text is usually Llama Guard 3 1B
(`llmgw ctl guard judge use qwen3guard-0.6b`), the wrong weights (another
repository's, without the judge's template: `judge enable --replace` serves
the right ones), or a policy counting every category: give its judge
`categories`. The baseline counts Violent, Non-violent Illegal Acts, Sexual
Content or Sexual Acts, Suicide & Self-Harm and Jailbreak; PII is the
patterns', and copyright, unethical acts and politically sensitive topics
refuse too much ordinary work. While finding out, `judge.input: flag`
records instead of refusing.
A judge's file served from the mirror page — Llama Guard, Qwen3Guard,
ShieldGemma — is served as a judge too, under the name the policies ask.
By hand, the catalog entry is:
```yaml
# a catalog model (server.yaml models:, or ctl model add)
- name: llama-guard-3-1b
source: http://mirror:8081/llama-guard-3-1b/…/Llama-Guard-3-1B-Q4_K_M.gguf
- name: qwen3guard-0.6b
source: http://mirror:8081/qwen3guard-0.6b/…/Qwen3Guard-Gen-0.6B.Q8_0.gguf
role: guard
guard:
format: llamaguard3 # llamaguard3, shieldgemma or json
categories: [Violent Crimes, Non-Violent Crimes, Sex-Related Crimes, …] # names for S1, S2, …
format: qwen3guard # qwen3guard, llamaguard3, shieldgemma or json
scaling: {min: 2, max: 2}
profiles: [{name: cpu, engine: llamacpp}]
# in a guard policy
judge:
model: llama-guard-3-1b
models: [qwen3guard-0.6b] # or model: one judge
input: block # flag or block; empty does not ask about input
output: flag
categories: [S1, S10] # only these count; empty: any unsafe verdict
latency_budget: 250ms # then the policy's fail: open serves (flagged), closed refuses
categories: [Violent, Jailbreak] # only these count; empty: any unsafe verdict
controversial: safe # or unsafe
latency_budget: 250ms # each judge's wait; after the last, fail: open serves (flagged), closed refuses
```
A model with `role: guard` is the guardrails' alone:
- it is not in `/v1/models` or the chat page, and is refused (404) to
every key, as a name and as a fallback;
- it is `critical` by default and must always run — on demand is refused.
Give it two replicas on different nodes, because every judged request
waits on it.
- it must always run — on demand is refused. One on CPUs is `critical`
and should have two replicas on different nodes, because every judged
request waits on it.
The gateway's calls to it go past admission, accounting and the guardrails
themselves.
- **Formats:**
- `llamaguard3` reads Meta's "safe" / "unsafe
S1,S10".
- `qwen3guard` asks in Qwen3Guard's own words and reads "Safety: Safe |
Unsafe | Controversial" and its "Categories:".
- `llamaguard3` reads Meta's "safe" / "unsafe\nS1,S10"; give the catalog
entry `categories` to have S1 recorded as its name.
- `shieldgemma` asks Google's model whether the text breaks the listed
principles (`categories`, each a sentence) and reads Yes or No.
- `json` expects `{"safe": false, "categories": [...]}`.
@@ -144,6 +253,9 @@ S1,S10".
is read every 600 characters and at the end, so text between two
readings has gone out already; `stream: buffer_all` holds it until it
is read.
- **A long text** is read by its start and its end (about 6000
characters), so a message with a file pasted into it fits the judge's
context rather than overflowing it and going unjudged.
- **Cache:** the same text is not asked about twice within five minutes.
## Where a policy applies
+152 -7
View File
@@ -129,8 +129,9 @@ That is the whole step. In order, it:
`nvidia-smi` finds any, on CPU otherwise — and waits for it to install its
engine
9. closes llmgw's internal ports to everything but the cluster's networks
10. fetches Llama Guard 3 1B, the guardrails' judge, and runs two replicas
on the CPUs of nodes with room — this one, or the first that join
10. fetches Qwen3Guard-Gen 0.6B, the guardrails' judge, and runs two
replicas on the CPUs of nodes with room — this one, or the first that
join ([guardrails.md](guardrails.md#a-judge-model) has one on a GPU too)
11. fetches Qwen2.5 0.5B into the mirror and serves it on this node, so the
first thing anyone opens `/chat` to find is a model that answers. A
machine without room for it is told so within two minutes, and why.
@@ -241,8 +242,71 @@ password, change it, and issue yourself a key from the profile page.
### The configuration
`/etc/llmgw/server.yaml` is yours from here on. Three things in it are worth
knowing before you edit it:
There are two halves, and each change to either is in force within seconds,
on every machine it concerns, without anyone restarting anything.
**The cluster's settings** — guardrails, limits and prices, routing, the
reconciler, engines, notifications, the mirror's policy, models declared by
hand, offline, federation, facility budgets, backups' recipients — are kept in
the store, so every control plane runs on the same ones. Change them with
```
llmgw ctl config # what is in force, and its version
llmgw ctl config set router.retries 3 --note "flaky upstream"
llmgw ctl config edit # the whole document in $EDITOR
llmgw ctl config history # who changed what, when, and why
llmgw ctl config rollback 4 # back to version 4, as a new version
```
or on the console's **Settings** screen, or in a browser on the portal's
**Settings** page (`/admin/settings`, for administrators only: the document
can hold webhook secrets). The page edits the same document, lists its
history, and puts an earlier version back. A change is checked before it is
kept, so one that would not load is refused. A change made from a version
someone else has replaced since is refused too, rather than overwriting
theirs. Every change is recorded in the audit log, with the document's size
but not its contents.
Guardrails change live; anything else has every control plane start again on
the new settings, the standbys first and the leader half a minute later, so
one of them is always serving. Engines keep running through it.
The first control plane to start against a store brings in its server.yaml's
cluster keys — with a `baseline` guard policy, if the file had none — and
from then on those keys in any server.yaml are not read (the server says so
when it starts).
**The nodes' agents** are set the same way, under `agents`. `agents.default`
is for every node, and `agents.nodes.<node-id>` is for one node, laid over
the default:
```
llmgw ctl config set agents.default.heartbeat_interval 5s
llmgw ctl config set agents.nodes.cuda01.labels.gpu rtx6000
llmgw ctl config edit # fabrics, engines, supervisor, discovery…
```
Each node's `agent.yaml` then needs only what its agent must know before it
can be told anything: `node_id`, `server` or `servers`, `hostname`,
`mgmt_addr` and `tls`. Those keys are refused under `agents`.
- **Precedence.** Anything else in the file is still read, with the
cluster's settings over it.
- **Applying a change.** The control plane sends each node its settings, a
second apart. An agent whose settings changed keeps them beside its
`agent.yaml` (in `/var/lib/llmgw-agent/agent-settings.yaml`) and starts
again on them, its engines serving throughout.
- **Bad settings.** Settings that would not load are refused when they are
saved. If they somehow do not load beside one node's own file, that agent
keeps running on what it has and logs why.
- **Editing `agent.yaml` itself.** The agent notices the change within ten
seconds and starts again on it. If the edited file does not load, the
agent reports it and keeps running on what it had.
**This machine's own keys** stay in `/etc/llmgw/server.yaml`: `listen`,
`admin_listen`, `metrics_listen`, `agent_listen`, `agent_tls`, `auth.store`,
`secrets`, `tracing`, `monitoring`, `mirror.api` and `mirror.tls`, the backup
directory and key. The server notices the file change and starts again on it
by itself. Three things are worth knowing before you edit it:
- `admin_listen` is on loopback on purpose, and the server refuses any other
address unless `admin_remote: true`. Its API under `/api` adds models and
@@ -2101,14 +2165,33 @@ loaded at boot by `llmgw-firewall.service`):
"The cluster's networks" are, on each machine, the network of the interface
it reaches the control planes (or the other store members) on — or just
their address, when the route goes through a gateway. Anything else that
must reach those ports, a Prometheus on another subnet or nodes behind a
router, goes in `server.yaml` before the machines are set up:
their address, when the route goes through a gateway. `node add` opens
the control plane's to each node it adds when the node is on a network not
already let in: its subnet when the control plane is on it, its address alone
when a router is between them. Anything else that must reach those ports, a
Prometheus on another subnet, a second control plane's or a separate mirror's
for nodes behind a router, goes in `server.yaml`:
```yaml
firewall_networks: [10.0.5.0/24, 192.168.1.20]
```
**Turning it off and on.** Whether llmgw keeps this firewall is one of the
cluster's settings:
```
llmgw ctl config set firewall.enabled false # every machine's table taken out
llmgw ctl config set firewall.enabled true # and put back as it was
```
It is in force within seconds: each node's agent takes its table out or puts
it back, and each control plane's `llmgw-firewall-sync.path` (installed by
`server init` and `server add`; `sudo llmgw firewall sync --install` on one
set up before) does the same there. The rules files stay, so turning it back
on restores them. Nodes added while it is off get none. A separate mirror
machine and the store's members keep theirs for now. `firewall_networks`
changes reach the nodes the same way.
Only those ports are touched. The table's policy is to accept, so whatever
else the machine serves — and whatever firewall it already has — is left as
it was; the client API (`:8080`) is not closed, being meant to be reached.
@@ -2245,6 +2328,68 @@ A cluster set up online becomes offline the same way: `bundle import` on the
mirror and control plane, then the `offline:` block in `server.yaml` and a
restart.
## Stopping and starting the system
From a control plane:
```
llmgw status # does it serve; its control planes, nodes, models, store
llmgw stop --note "maintenance" # every model taken down, every request answered 503
llmgw start # every model back as its catalog entry asks
sudo llmgw restart # every agent and control plane started again, engines serving throughout
```
`llmgw stop` frees every GPU and answers requests for a model with 503
(`system_stopped`, with Retry-After) until `llmgw start`. The control planes,
agents and mirror keep running, so the console, the admin pages and `llmgw
start` keep working, and no model's definition changes. It is the cluster's
setting `serving.stopped`, so a restart does not undo it, and it is in the
audit with its note. `--wait` waits until every replica is down, or every
model has its replicas again.
`llmgw restart` restarts every node's agent, then the other control planes
over ssh (`--ssh-user`, the one sudo was run by), then this one — the leader
last, so it hands over to a standby already restarted. Engines keep serving
and are taken over again; nothing is placed anew.
One machine's own services are `llmgw service`:
```
sudo llmgw service status
sudo llmgw service stop # agent, control plane, mirror — whichever this machine has
sudo llmgw service start
sudo llmgw service restart agent # or name one: server, agent, mirror
```
Stopping a node's agent leaves its engines serving — the agent takes them
over again when it starts — unless `llmgw service stop --engines` stops them
too, and the control plane then places their replicas elsewhere.
## Removing llmgw from a machine
```
sudo llmgw wipe # services, configuration, certificates, state, users
sudo llmgw wipe --models # and model weights, the mirror's files, engines
sudo llmgw wipe --store # and the store's database here (Patroni member, etcd, or local PostgreSQL)
sudo llmgw wipe --all # all of that, backups and the binary
```
It lists everything it will remove and asks for the machine's name (`--yes`
does not ask), then stops llmgw's services and the engines, takes the
firewall table out, and deletes `/etc/llmgw` — the authority and secrets
with it — `/var/lib/llmgw`, the agent's state, the sudo grant, the
authority from the system's trust, keepalived's configuration when llmgw
wrote it, and the `llmgw` and `llmgw-engine` users. Packages it installed
(PostgreSQL, Patroni, etcd, nftables, the node exporter) stay. Run it again
to finish what failed. A node wiped is then forgotten on the control plane
with `llmgw node remove <name>`. The console has it too: **Do → Wipe this
machine**.
This is how a machine with an older install is set up again from nothing:
wipe it, then install as above. Wiping a control plane without `--store`
leaves the store with the cluster's accounts, keys and settings in it, and a
new control plane against that store takes them up.
## Kubernetes
The control plane runs in Kubernetes from the Helm chart in
+349
View File
@@ -0,0 +1,349 @@
# 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"), has the gateway embed each passage with the person's own
key, and sends 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.
## 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.
## 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. |
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, two things leave the machine, both over
the gateway's TLS connection:
- **The passages, to be embedded.** They go through `/v1/embeddings` like
any request of yours. Your key's limits, budget and guardrails apply.
- **The document itself.** Its name, its file's SHA-256 digest and size,
and the passages with their vectors.
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 its organization. One organization's
passages then never share cached blocks with another's, so how fast an
answer starts says nothing about what another tenant 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`.
## 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.
+27 -5
View File
@@ -379,16 +379,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
+1 -1
View File
@@ -52,7 +52,7 @@ install:
- your administrator account. **The password is shown once. Copy it now.**
- a model mirror, which downloads model files once for the whole cluster;
- this machine as a first node, and a small model (Qwen2.5 0.5B) on it;
- guardrails on every request, with a judge model (Llama Guard 3 1B, two
- guardrails on every request, with a judge model (Qwen3Guard-Gen 0.6B, two
replicas) on CPUs — a request it cannot check is refused, not let through — and API keys that expire within 90 days. What is hardened, and how
to loosen it: [install.md](install.md#2-the-control-plane).
+167 -5
View File
@@ -26,6 +26,8 @@ rather than repeated.
| `queue_capacity_per_band` | `int` | [Admission](#admission) |
| `queue_capacity_total` | `int` | [Admission](#admission) |
| `interval` | `time.Duration` | [Admission](#admission) |
| `default` | `map[string]any` | [AgentSettings](#agentsettings) |
| `nodes` | `map[string]map[string]any` | [AgentSettings](#agentsettings) |
| `enabled` | `bool` | [Auth](#auth) |
| `store` | `string` | [Auth](#auth) |
| `cache_ttl` | `time.Duration` | [Auth](#auth) |
@@ -98,12 +100,15 @@ rather than repeated.
| `other_watts` | `float64` | [FacilityBudget](#facilitybudget) |
| `name` | `string` | [Federation](#federation) |
| `interval` | `time.Duration` | [Federation](#federation) |
| `enabled` | `bool` | [FirewallSettings](#firewallsettings) |
| `format` | `string` | [GuardModel](#guardmodel) |
| `categories` | `[]string` | [GuardModel](#guardmodel) |
| `model` | `string` | [JudgePolicy](#judgepolicy) |
| `models` | `[]string` | [JudgePolicy](#judgepolicy) |
| `input` | `string` | [JudgePolicy](#judgepolicy) |
| `output` | `string` | [JudgePolicy](#judgepolicy) |
| `categories` | `[]string` | [JudgePolicy](#judgepolicy) |
| `controversial` | `string` | [JudgePolicy](#judgepolicy) |
| `latency_budget` | `time.Duration` | [JudgePolicy](#judgepolicy) |
| `tokenize_timeout` | `time.Duration` | [KVIndex](#kvindex) |
| `min_blocks` | `int` | [KVIndex](#kvindex) |
@@ -202,6 +207,7 @@ rather than repeated.
| `files` | `[]OriginFile` | [PreviousOrigin](#previousorigin) |
| `name` | `string` | [Profile](#profile) |
| `engine` | `string` | [Profile](#profile) |
| `engine_version` | `string` | [Profile](#profile) |
| `gpu_class` | `string` | [Profile](#profile) |
| `tp` | `int` | [Profile](#profile) |
| `pp` | `int` | [Profile](#profile) |
@@ -287,6 +293,10 @@ rather than repeated.
| `guard` | `guard.Config` | [Server](#server) |
| `secrets` | `Secrets` | [Server](#server) |
| `notify` | `notify.Config` | [Server](#server) |
| `mail` | `mail.Config` | [Server](#server) |
| `serving` | `ServingSettings` | [Server](#server) |
| `firewall` | `FirewallSettings` | [Server](#server) |
| `agents` | `AgentSettings` | [Server](#server) |
| `firewall_networks` | `[]string` | [Server](#server) |
| `backup_recipients` | `[]string` | [Server](#server) |
| `backup_copy_to` | `[]string` | [Server](#server) |
@@ -298,6 +308,7 @@ rather than repeated.
| `facility` | `Facility` | [Server](#server) |
| `federation` | `Federation` | [Server](#server) |
| `endpoints` | `[]Endpoint` | [Server](#server) |
| `stopped` | `bool` | [ServingSettings](#servingsettings) |
| `bin` | `string` | [SimConfig](#simconfig) |
| `python` | `string` | [TRTLLMConfig](#trtllmconfig) |
| `store` | `string` | [Threads](#threads) |
@@ -370,8 +381,14 @@ rather than repeated.
| `llamacpp` | `LlamaCppConfig` | [engines.Config](#enginesconfig) |
| `trtllm` | `TRTLLMConfig` | [engines.Config](#enginesconfig) |
| `sim` | `SimConfig` | [engines.Config](#enginesconfig) |
| `enabled` | `*bool` | [guard.Config](#guardconfig) |
| `default` | `[]string` | [guard.Config](#guardconfig) |
| `policies` | `map[string]Policy` | [guard.Config](#guardconfig) |
| `smtp` | `string` | [mail.Config](#mailconfig) |
| `from` | `string` | [mail.Config](#mailconfig) |
| `username` | `string` | [mail.Config](#mailconfig) |
| `password_file` | `string` | [mail.Config](#mailconfig) |
| `base_url` | `string` | [mail.Config](#mailconfig) |
| `webhooks` | `[]Webhook` | [notify.Config](#notifyconfig) |
| `host` | `string` | [notify.Config](#notifyconfig) |
| `work_dir` | `string` | [supervisor.Config](#supervisorconfig) |
@@ -455,6 +472,21 @@ Zero takes half of all the bands' capacities together.
`time.Duration`
## AgentSettings
### `default`
`map[string]any`
Default is agent.yaml's keys for every node.
### `nodes`
`map[string]map[string]any`
Nodes are agent.yaml's keys for one node each, by node id, over
Default.
## Auth
### `enabled`
@@ -963,15 +995,30 @@ Interval is how often each peer is asked whether it is ready, timed,
and what it offers. A peer that does not answer is taken out of
routing until it does. Zero is 10s.
## FirewallSettings
### `enabled`
`bool`
Enabled keeps llmgw's internal ports — engines, node exporter, the
agents' stream, the mirror, the store — to the cluster's networks on
every machine. False removes llmgw's nftables table everywhere, for a
site whose own firewall does that job; the rules are kept, and true
puts them back. Changed in the cluster's settings, it is in force on
every node and control plane within seconds.
## GuardModel
### `format`
`string`
Format is llamaguard3 (Meta's Llama Guard 3: "safe", or "unsafe" and
the categories), shieldgemma (Google's ShieldGemma: Yes or No), or
json ({"safe": false, "categories": ["..."]}).
Format is qwen3guard (Qwen3Guard-Gen: "Safety: Safe", "Unsafe" or
"Controversial", and the categories), llamaguard3 (Meta's Llama Guard
3: "safe", or "unsafe" and the categories), shieldgemma (Google's
ShieldGemma: Yes or No), or json ({"safe": false, "categories":
["..."]}).
### `categories`
@@ -988,6 +1035,15 @@ sentence — and the names reported for llamaguard3's S1, S2, ….
Model is a catalog model with role: guard.
### `models`
`[]string`
Models are judges asked in turn, instead of Model: the first that
answers gives the verdict. A judge on a GPU first and one on CPUs
after it is the GPU's speed while it is there, and the CPUs' when it
is not — busy, failed, or the node down.
### `input`
`string`
@@ -1006,12 +1062,19 @@ Empty does not ask the judge about that direction.
Categories, when set, are the only ones that count: Llama Guard's
codes (S1, S10) or the names the model reports.
### `controversial`
`string`
Controversial is what Qwen3Guard's third verdict counts as: safe
(the default) or unsafe.
### `latency_budget`
`time.Duration`
LatencyBudget is how long the judge is waited for before the policy's
fail applies. Zero is 250ms.
LatencyBudget is how long each judge is waited for before the next is
asked, or, after the last, the policy's fail applies. Zero is 250ms.
## KVIndex
@@ -1740,6 +1803,14 @@ before affinity is abandoned.
`string`
### `engine_version`
`string`
EngineVersion restricts the profile to nodes whose installed engine's
version starts with it: "0.11" takes vLLM 0.11.0 and 0.11.1, not
0.10.2. Empty takes whatever version the node has.
### `gpu_class`
`string`
@@ -2441,6 +2512,44 @@ Prometheus and Alertmanager in between. Empty sends nothing.
Takes a [notify.Config](#notifyconfig) block.
### `mail`
`mail.Config`
Mail is where the gateway's few emails go out from: a link to set a
forgotten password. Unset, none is sent and people ask an operator.
Takes a [mail.Config](#mailconfig) block.
### `serving`
`ServingSettings`
Serving stops and starts the whole cluster's service (llmgw stop, llmgw
start).
Takes a [ServingSettings](#servingsettings) block.
### `firewall`
`FirewallSettings`
Firewall turns llmgw's firewall on every machine on or off.
Takes a [FirewallSettings](#firewallsettings) block.
### `agents`
`AgentSettings`
Agents are the nodes' agent settings: agent.yaml's keys but those an
agent needs to start, for every node (default) and for one
(nodes.<id>). Sent to the agents as they change; an agent whose
settings changed starts again on them, its engines serving
throughout.
Takes a [AgentSettings](#agentsettings) block.
### `firewall_networks`
`[]string`
@@ -2531,6 +2640,18 @@ Endpoints is the phase-1 static replica list. It is only consulted when
no model declares a profile, which is what makes a router-only smoke test
against sim-engines possible without a control plane.
## ServingSettings
### `stopped`
`bool`
Stopped takes every model down and answers every request for one with
503 (system_stopped), leaving the control planes, agents and mirror
running and every model's definition as it is: llmgw stop sets it,
llmgw start clears it, and the models come back as their catalog
entries ask. In force on every control plane within seconds.
## SimConfig
### `bin`
@@ -3044,6 +3165,13 @@ Takes a [SimConfig](#simconfig) block.
## guard.Config
### `enabled`
`*bool`
Enabled turns every policy on or off at once: false checks nothing,
whatever is defined or attached. Unset is on.
### `default`
`[]string`
@@ -3052,6 +3180,40 @@ Takes a [SimConfig](#simconfig) block.
`map[string]Policy`
## mail.Config
### `smtp`
`string`
SMTP is the relay, host:port: 587 with STARTTLS, 465 with TLS from the
start, 25 as the relay allows. Empty sends nothing.
### `from`
`string`
From is the sender, llmgw@example.com or "llmgw <llmgw@example.com>".
### `username`
`string`
Username and PasswordFile log in to the relay, when it wants that.
The password is a secret reference: a file, env:NAME or vault:path.
### `password_file`
`string`
### `base_url`
`string`
BaseURL is the gateway's public address, which links in mail point
at: https://llm.example.com. Never taken from a request, whose Host a
sender chooses and could point a reset link at their own site.
## notify.Config
### `webhooks`
+2
View File
@@ -18,6 +18,7 @@ require (
github.com/knadh/koanf/providers/env/v2 v2.0.1
github.com/knadh/koanf/providers/file v1.2.1
github.com/knadh/koanf/v2 v2.3.6
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0
github.com/mattn/go-isatty v0.0.24
github.com/prometheus/client_golang v1.24.1
github.com/prometheus/client_model v0.6.3
@@ -34,6 +35,7 @@ require (
golang.org/x/net v0.59.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.23.0
golang.org/x/term v0.46.0
golang.org/x/text v0.42.0
google.golang.org/protobuf v1.36.12
modernc.org/sqlite v1.59.0
+2
View File
@@ -95,6 +95,8 @@ github.com/knadh/koanf/v2 v2.3.6 h1:JoQPSJmvS4aP0xNc8xMDr5tcrkSEInL23/Il7pITAKo=
github.com/knadh/koanf/v2 v2.3.6/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0 h1:7Q+xNAZFmnfYOMweHN3c/PDFUKKfY1pVJ26K++QvVfU=
github.com/ledongthuc/pdf v0.0.0-20260907135840-6c8c28e0e8a0/go.mod h1:1fEHWurg7pvf5SG6XNE5Q8UZmOwex51Mkx3SLhrW5B4=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
+37
View File
@@ -448,3 +448,40 @@ func TestAHashThatCannotGetATurnSaysTheServerIsBusy(t *testing.T) {
t.Errorf("a hash with no turn: %v", err)
}
}
func TestAForgottenPasswordIsResetOnceThroughALink(t *testing.T) {
svc, st := newService(t)
ctx := context.Background()
must(t, svc.SignUp(ctx, "ayse@example.com", "A", goodPassword))
u, _ := st.UserByEmail(ctx, "ayse@example.com")
// Waiting for approval: no link, said the same as for nobody.
if _, _, err := svc.RequestReset(ctx, st, "ayse@example.com"); !errors.Is(err, ErrNoReset) {
t.Fatalf("a pending account got a link: %v", err)
}
if _, _, err := svc.RequestReset(ctx, st, "nobody@example.com"); !errors.Is(err, ErrNoReset) {
t.Fatalf("an unknown address: %v", err)
}
must(t, st.ApproveUser(ctx, u.ID, "proj", "ilker", time.Now()))
session, _, err := svc.SignIn(ctx, "ayse@example.com", goodPassword)
must(t, err)
token, got, err := svc.RequestReset(ctx, st, "AYSE@example.com")
if err != nil || token == "" || got.ID != u.ID {
t.Fatalf("an active account: %q %+v %v", token, got, err)
}
if err := svc.ResetPassword(ctx, st, st, token, "short"); !errors.Is(err, ErrWeakPassword) {
t.Fatalf("a weak password: %v", err)
}
next := goodPassword + "-new"
must(t, svc.ResetPassword(ctx, st, st, token, next))
if _, _, err := svc.Resolve(ctx, session); err == nil {
t.Error("a session from before the reset still works")
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", next); err != nil {
t.Errorf("the new password does not sign in: %v", err)
}
if err := svc.ResetPassword(ctx, st, st, token, next+"2"); !errors.Is(err, ErrNoReset) {
t.Errorf("a link used twice: %v", err)
}
}
+60
View File
@@ -373,3 +373,63 @@ 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)
if err != nil || 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
}
+128 -4
View File
@@ -36,6 +36,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
"gitea.mynodes.xyz/ilker/llmgw/internal/kvevents"
"gitea.mynodes.xyz/ilker/llmgw/internal/obs"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
@@ -87,7 +88,11 @@ type Agent struct {
// serverList is every control plane to dial: the configured ones, and
// any the leader has named since. Read through servers().
serverList []string
inv discovery.Inventory
// fwMu keeps one firewall change at a time; fwRunner runs its scripts
// (nil: this machine's shell).
fwMu sync.Mutex
fwRunner firewall.Runner
inv discovery.Inventory
// shardReplica remembers which replica a shard belongs to, so a status
// report can name both. The control plane keys on the replica.
shardReplica map[string]string
@@ -131,6 +136,10 @@ type Agent struct {
// by mu.
kv *kvevents.Mirror
kvSubs map[string]*kvevents.Subscriber
// settingsCheck says whether the cluster's settings for this agent
// would load beside its agent.yaml; see SetSettingsCheck.
settingsCheck func([]byte) error
}
// connectHTTPClient is the HTTP client connect uses; an interface so tests can
@@ -366,6 +375,12 @@ func (a *Agent) settleAdopted(sh *supervisor.Shard, o supervisor.Orphan) {
_ = a.sup.Stop(ctx, o.ShardID)
return
}
// What it computes on, for one started before the agent kept that.
if a.sup.ComputeOf(o.ShardID) == nil {
if c, ok := a.gpuCompute(ctx, o.Spec.GPUIndices, sh.Status().PID); ok {
a.sup.SetCompute(o.ShardID, c)
}
}
a.log.Info("adopted engine is serving",
"shard", o.ShardID, "replica", o.ReplicaID)
}
@@ -406,6 +421,17 @@ func (a *Agent) weights() *weights.Fetcher {
}
// runningShards describes what this agent is supervising, for registration.
// unready are the shards a registration reported as not yet ready.
func unready(running []*llmgwv1.RunningShard) map[string]bool {
out := map[string]bool{}
for _, r := range running {
if r.GetState() != agentsvc.StateToWire(supervisor.Ready) {
out[r.GetShardId()] = true
}
}
return out
}
func (a *Agent) runningShards() []*llmgwv1.RunningShard {
var out []*llmgwv1.RunningShard
for _, st := range a.sup.Statuses() {
@@ -431,6 +457,7 @@ func (a *Agent) runningShards() []*llmgwv1.RunningShard {
Pool: spec.Pool,
Fabric: spec.Fabric,
ProfileFp: spec.ProfileFP,
Compute: computeToWire(a.sup.ComputeOf(st.ID)),
LeaderAddr: a.cfg.MgmtAddr,
LeaderPort: int32(spec.LeaderPort),
})
@@ -559,8 +586,9 @@ func (a *Agent) session(ctx context.Context, server string) error {
a.sendLoop(ctx, stream, out)
}()
defer wg.Wait()
go a.watchExits(ctx, out)
go a.watchExits(ctx, out, unready(reg.RunningShards))
go a.forwardKV(ctx, out)
go a.reportCompute(ctx, out)
for {
msg, err := stream.Receive()
@@ -854,6 +882,18 @@ func (a *Agent) handle(ctx context.Context, msg *llmgwv1.ControlMessage, out cha
go a.upgrade(ctx, m.Upgrade, out)
case *llmgwv1.ControlMessage_ControlPlanes:
a.setControlPlanes(m.ControlPlanes)
case *llmgwv1.ControlMessage_Firewall:
go a.applyFirewall(m.Firewall)
case *llmgwv1.ControlMessage_AgentSettings:
go a.applySettings(m.AgentSettings)
case *llmgwv1.ControlMessage_RestartAgent:
go func() {
a.log.Info("restarting, as the control plane asks; the engines keep serving",
"reason", m.RestartAgent.GetReason())
// A moment for the log line to leave before the process does.
time.Sleep(300 * time.Millisecond)
exitForUpgrade()
}()
case *llmgwv1.ControlMessage_Logs:
reply := a.logsReply(m.Logs)
// Off the receive loop: a full send queue must not stop commands being
@@ -1123,6 +1163,14 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
} else {
err = a.sup.WaitAlive(ctx, sh)
}
// Where it put the model, by its own account: said with the readiness,
// and a GPU placement it computes on the CPU refused even where
// nvidia-smi cannot be asked.
compute, said, cerr := engineCompute(eng, spec.GPUIndices, sh.Logs(512))
if err == nil && cerr != nil {
err = cerr
_ = a.sup.Stop(ctx, shardID)
}
if err == nil {
err = a.onItsGPUs(ctx, eng, spec, sh.Status().PID)
if err != nil {
@@ -1135,7 +1183,19 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
report(supervisor.Failed, err.Error(), sh.Logs(20))
return
}
report(supervisor.Ready, "", nil)
// The engine's own account when it gave one, with its layers; else what
// nvidia-smi sees the shard holding.
if !said {
compute, said = a.gpuCompute(ctx, spec.GPUIndices, sh.Status().PID)
}
if said {
// Kept with the shard, so an agent restarted, or a control plane
// taking over, still learns it.
a.sup.SetCompute(shardID, compute)
send(&llmgwv1.ShardStatus{State: agentsvc.StateToWire(supervisor.Ready), Compute: computeToWire(&compute)})
} else {
report(supervisor.Ready, "", nil)
}
// Its dying later is reported by the session's watchExits.
}
@@ -1148,7 +1208,14 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
// reported the engine dying; and an adopted engine had no watcher at all —
// on node06 one was killed and its replica stayed "ready" for the control
// plane, routed to and failing, indefinitely.
func (a *Agent) watchExits(ctx context.Context, out chan<- *llmgwv1.AgentMessage) {
//
// It also reports the shards the registration said were not ready yet once
// they are. An adopted engine is health-checked after the agent registers,
// and a shard started on the stream before is reported on that stream; so
// when the registration came first nothing ever told the control plane, and
// the replica stayed "starting" while it served — on node05, after a
// control plane restart, the only replica of a model.
func (a *Agent) watchExits(ctx context.Context, out chan<- *llmgwv1.AgentMessage, pending map[string]bool) {
t := time.NewTicker(time.Second)
defer t.Stop()
ready := map[string]bool{}
@@ -1157,6 +1224,24 @@ func (a *Agent) watchExits(ctx context.Context, out chan<- *llmgwv1.AgentMessage
switch st.State {
case supervisor.Ready:
ready[st.ID] = true
if !pending[st.ID] {
continue
}
delete(pending, st.ID)
sh, err := a.sup.Get(st.ID)
if err != nil {
continue
}
msg := &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Shard{Shard: &llmgwv1.ShardStatus{
NodeId: a.cfg.NodeID, ReplicaId: sh.Spec.ReplicaID, ShardId: st.ID,
State: agentsvc.StateToWire(st.State),
Compute: computeToWire(a.sup.ComputeOf(st.ID)),
}}}
select {
case out <- msg:
case <-ctx.Done():
return
}
case supervisor.Failed:
if !ready[st.ID] {
continue // never ready here: its start reported it
@@ -1366,3 +1451,42 @@ func ResolveManagement(c Config) (addr, iface string) {
}
return addr, iface
}
// reportCompute tells the control plane, once a session, what each running
// shard computes on as soon as that is known: an adopted engine's is found
// after registration, which carried what was known then.
func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMessage) {
sent := map[string]bool{}
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for range 60 {
for _, st := range a.sup.Statuses() {
if st.State.Terminal() || sent[st.ID] {
continue
}
c := a.sup.ComputeOf(st.ID)
if c == nil {
continue
}
sh, err := a.sup.Get(st.ID)
if err != nil {
continue
}
// No state: a report of compute alone, not a transition.
msg := &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Shard{Shard: &llmgwv1.ShardStatus{
NodeId: a.cfg.NodeID, ReplicaId: sh.Spec.ReplicaID, ShardId: st.ID, Compute: computeToWire(c),
}}}
select {
case out <- msg:
sent[st.ID] = true
case <-ctx.Done():
return
}
}
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
+12 -5
View File
@@ -37,11 +37,12 @@ import (
type statusRecorder struct {
ch chan status
mu sync.Mutex
calls []adoptCall
adopted []reconciler.AdoptedShard
unclaim []string // shard ids to report back as unclaimed
fetches []fetchReport
mu sync.Mutex
calls []adoptCall
adopted []reconciler.AdoptedShard
unclaim []string // shard ids to report back as unclaimed
fetches []fetchReport
computes []reconciler.Compute
}
type status struct {
@@ -61,6 +62,12 @@ 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) 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 string, done, total int64) {
r.mu.Lock()
defer r.mu.Unlock()
+5
View File
@@ -103,6 +103,11 @@ type Config struct {
Tracing obs.Tracing `koanf:"tracing"`
Version string
// Settings is the cluster's settings for this agent it was started on,
// as the control plane sent them: what a newly sent version is
// compared with.
Settings string `koanf:"-"`
}
// DefaultConfig returns sensible defaults.
+79
View File
@@ -0,0 +1,79 @@
package engines
import (
"regexp"
"strconv"
"strings"
)
// Compute is where an engine runs its model, as it said while loading it.
type Compute struct {
// Backend is CUDA, Vulkan, Metal, ROCm or CPU.
Backend string
// Devices are those it named: "CUDA0: Quadro RTX 6000 (7.5)".
Devices []string
// Offloaded of Total layers went to the devices; 0 and 0 when it did
// not say.
Offloaded, Total int
// NoGPU is the engine saying it found no device it could use.
NoGPU bool
}
var (
offloadedRE = regexp.MustCompile(`offloaded (\d+)/(\d+) layers to GPU`)
usingRE = regexp.MustCompile(`using device ([A-Za-z]+\d+) \(([^)]*)\)`)
ccRE = regexp.MustCompile(`Device (\d+): ([^,]+), compute capability ([0-9.]+)`)
noDevicesRE = regexp.MustCompile(`found 0 (CUDA|Vulkan|ROCm) devices|no usable GPU found`)
)
// ReadLlamaCompute reads llama.cpp's start-up output for where it put the
// model, reporting false when the output says nothing of it.
func ReadLlamaCompute(lines []string) (Compute, bool) {
var c Compute
known := false
cc := map[string]string{} // device index → compute capability
for _, l := range lines {
if m := ccRE.FindStringSubmatch(l); m != nil {
cc[m[1]] = m[3]
}
}
seen := map[string]bool{}
for _, l := range lines {
if m := offloadedRE.FindStringSubmatch(l); m != nil {
c.Offloaded, _ = strconv.Atoi(m[1])
c.Total, _ = strconv.Atoi(m[2])
known = true
}
if m := usingRE.FindStringSubmatch(l); m != nil && !seen[m[1]] {
seen[m[1]] = true
d := m[1] + ": " + strings.TrimSpace(m[2])
idx := strings.TrimLeft(m[1], "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz")
if v, ok := cc[idx]; ok && strings.HasPrefix(m[1], "CUDA") {
d += " (" + v + ")"
}
c.Devices = append(c.Devices, d)
if c.Backend == "" {
c.Backend = strings.TrimRight(m[1], "0123456789")
}
known = true
}
if noDevicesRE.MatchString(l) {
c.NoGPU, known = true, true
}
}
switch {
case c.OnTheCPU():
c.Backend = "CPU"
case c.Backend == "" && c.Offloaded > 0:
c.Backend = "GPU"
case c.Backend == "":
c.Backend = "CPU"
}
return c, known
}
// OnTheCPU is whether a model the engine was given GPUs for is computed on
// the CPU, by its own account: no device found, or no layer offloaded.
func (c Compute) OnTheCPU() bool {
return c.NoGPU || (c.Total > 0 && c.Offloaded == 0)
}
+32
View File
@@ -0,0 +1,32 @@
package engines
import (
"strings"
"testing"
)
func TestLlamaCppSaysWhereItPutTheModel(t *testing.T) {
gpu := strings.Split(`ggml_cuda_init: found 1 CUDA devices:
Device 0: Quadro RTX 6000, compute capability 7.5, VMM: yes
load_backend: loaded CUDA backend from /opt/llmgw/engines/llamacpp/b10991/libggml-cuda.so
llama_model_load_from_file_impl: using device CUDA0 (Quadro RTX 6000) - 23800 MiB free
load_tensors: offloading 48 repeating layers to GPU
load_tensors: offloaded 49/49 layers to GPU`, "\n")
c, ok := ReadLlamaCompute(gpu)
if !ok || c.Backend != "CUDA" || c.Offloaded != 49 || c.Total != 49 || c.OnTheCPU() ||
len(c.Devices) != 1 || c.Devices[0] != "CUDA0: Quadro RTX 6000 (7.5)" {
t.Errorf("on the GPU: %+v %v", c, ok)
}
cpu := strings.Split(`warning: no usable GPU found, --gpu-layers option will be ignored
warning: one possible reason is that llama.cpp was compiled without GPU support
load_tensors: offloaded 0/49 layers to GPU`, "\n")
c, ok = ReadLlamaCompute(cpu)
if !ok || c.Backend != "CPU" || !c.OnTheCPU() {
t.Errorf("fell back to the CPU: %+v %v", c, ok)
}
if _, ok := ReadLlamaCompute([]string{"main: server is listening on http://127.0.0.1:20001"}); ok {
t.Error("output that says nothing was read as saying something")
}
}
+4
View File
@@ -114,6 +114,10 @@ type ShardSpec struct {
// lets the control plane tell, after its own restart, whether a shard that
// is still running matches the profile the catalog holds now.
ProfileFP string
// Compute is where the engine said it put the model as it became ready,
// kept with the shard's state so an agent restarted later still knows.
Compute *Compute `json:",omitempty"`
}
// Host is where the engine listens.
+3
View File
@@ -277,6 +277,9 @@ func (l *LlamaCpp) bin() string {
return "llama-server"
}
// Binary is the llama-server this engine runs.
func (l *LlamaCpp) Binary() string { return l.bin() }
// FallsBackToCPU says that llama.cpp keeps running when its GPU backend does
// not load: --n-gpu-layers asks for layers on a GPU it then has none of, and
// the server comes up and answers on the CPU, at a tenth of the speed, with no
+89
View File
@@ -0,0 +1,89 @@
package agent
import (
"context"
"net"
"net/netip"
"net/url"
"strings"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
)
// applyFirewall keeps llmgw's firewall on this node as the cluster's
// settings say: the engines' ports and the node exporter's for the networks
// the node reaches its control planes on, and those named, or no table at
// all. Off the receive loop, one at a time.
func (a *Agent) applyFirewall(m *llmgwv1.Firewall) {
a.fwMu.Lock()
defer a.fwMu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
r := a.fwRunner
if r == nil {
r = firewall.Local{}
}
if !m.GetEnabled() {
had, err := firewall.Disable(ctx, r)
switch {
case err != nil:
a.log.Warn("could not take llmgw's firewall off this node", "err", err)
case had:
a.log.Info("llmgw's firewall is off on this node, as the cluster's settings say")
}
return
}
a.mu.Lock()
servers := append([]string(nil), a.serverList...)
a.mu.Unlock()
nets, err := firewall.Networks(ctx, r, controlPlaneIPs(ctx, servers))
if err != nil {
a.log.Warn("could not tell which networks reach the control planes; the firewall is left as it is", "err", err)
return
}
for _, n := range m.GetNetworks() {
if p, err := netip.ParsePrefix(n); err == nil {
nets = append(nets, p)
} else if ad, err := netip.ParseAddr(n); err == nil {
nets = append(nets, netip.PrefixFrom(ad, ad.BitLen()))
}
}
from, to := int(m.GetEnginePortsFrom()), int(m.GetEnginePortsTo())
ports := []firewall.Port{firewall.NodeExporter}
if from > 0 && to >= from {
ports = append(ports, firewall.Engines(from, to))
}
changed, err := firewall.Install(ctx, r, firewall.Spec{Ports: ports, Allow: nets})
switch {
case err != nil:
a.log.Warn("could not put llmgw's firewall on this node", "err", err)
case changed:
a.log.Info("llmgw's firewall is on this node", "networks", len(nets))
}
}
// controlPlaneIPs are the addresses of the control planes an agent dials.
func controlPlaneIPs(ctx context.Context, servers []string) []string {
var out []string
for _, s := range servers {
host := s
if u, err := url.Parse(s); err == nil && u.Host != "" {
host = u.Hostname()
}
host = strings.Trim(host, "[]")
if a, err := netip.ParseAddr(host); err == nil {
out = append(out, a.String())
continue
}
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip", host)
if err != nil {
continue
}
for _, ip := range ips {
out = append(out, ip.Unmap().String())
}
}
return out
}
+57
View File
@@ -0,0 +1,57 @@
package agent
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
// fakeShell answers the firewall package's scripts and records what was
// installed or removed.
type fakeShell struct {
present bool
installed string
deleted bool
}
func (f *fakeShell) Run(_ context.Context, script string, stdin []byte) ([]byte, error) {
switch {
case strings.HasPrefix(script, "ip -o route get 10.0.0.140"):
return []byte("10.0.0.140 dev eth0 src 10.0.0.141 uid 0\n2: eth0 inet 10.0.0.141/24 brd 10.0.0.255 scope global eth0\n"), nil
case strings.HasPrefix(script, "sudo -n /usr/sbin/nft list table"):
if f.present {
return nil, nil
}
return nil, errors.New("no table")
case strings.Contains(script, "nft delete table") && !strings.Contains(script, "cat > "):
f.deleted, f.present = true, false
return nil, nil
case strings.HasPrefix(script, "sudo cat "):
return nil, nil
default:
f.installed, f.present = string(stdin), true
return []byte("changed\n"), nil
}
}
func TestANodeKeepsOrDropsItsFirewallAsTheClusterSays(t *testing.T) {
sh := &fakeShell{}
a := &Agent{log: slog.New(slog.DiscardHandler), fwRunner: sh, serverList: []string{"https://10.0.0.140:7443"}}
a.applyFirewall(&llmgwv1.Firewall{Enabled: true, Networks: []string{"192.168.5.0/24", "10.9.9.9"},
EnginePortsFrom: 20000, EnginePortsTo: 20999})
for _, want := range []string{"10.0.0.0/24", "192.168.5.0/24", "10.9.9.9/32", "20000-20999", "9100"} {
if !strings.Contains(sh.installed, want) {
t.Errorf("the rules lack %s:\n%s", want, sh.installed)
}
}
a.applyFirewall(&llmgwv1.Firewall{Enabled: false})
if !sh.deleted {
t.Error("turned off, the table stayed")
}
}
+69
View File
@@ -4,10 +4,12 @@ import (
"context"
"fmt"
"os"
"slices"
"strconv"
"strings"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
)
@@ -153,3 +155,70 @@ func joinIndices(is []int) string {
}
return strings.Join(s, ", ")
}
// engineCompute is where an engine said it put its model, from its output
// so far — llama.cpp's, which says — and false for one that said nothing. A
// shard given GPUs that its engine says it computes on the CPU is an error,
// found this way even where nvidia-smi cannot be asked.
func engineCompute(eng engines.Engine, gpus []int, lines []string) (engines.Compute, bool, error) {
if _, ok := eng.(engines.CPUFallback); !ok {
return engines.Compute{}, false, nil
}
read, ok := engines.ReadLlamaCompute(lines)
if !ok {
return engines.Compute{}, false, nil
}
if len(gpus) > 0 && read.OnTheCPU() {
why := "its GPU backend saw no device it could use"
if read.Total > 0 && !read.NoGPU {
why = fmt.Sprintf("it offloaded %d of %d layers", read.Offloaded, read.Total)
}
return read, true, fmt.Errorf("placed on GPU %v but computing on the CPU: %s, by llama.cpp's own account "+
"(llmgw ctl engine-check on this node says why)", gpus, why)
}
return read, true, nil
}
// computeToWire is a compute as it travels to the control plane.
func computeToWire(c *engines.Compute) *llmgwv1.Compute {
if c == nil {
return nil
}
return &llmgwv1.Compute{Backend: c.Backend, Devices: c.Devices,
LayersOffloaded: int32(c.Offloaded), LayersTotal: int32(c.Total)} //nolint:gosec // layer counts
}
// gpuCompute is where a shard computes as nvidia-smi sees it: the CPU for a
// shard given no GPU, else the GPUs whose memory its processes hold, and how
// much. False when nvidia-smi cannot be asked. Engine-agnostic, and needing
// no log line: llama.cpp says where its layers went only at -lv 4.
func (a *Agent) gpuCompute(ctx context.Context, gpus []int, pid int) (engines.Compute, bool) {
if len(gpus) == 0 {
return engines.Compute{Backend: "CPU"}, true
}
mem, err := gpuMemory(ctx, a.cfg.Discovery.NvidiaSMI)
if err != nil || mem == nil || pid <= 0 {
return engines.Compute{}, false
}
group := processGroup(pid)
c := engines.Compute{Backend: "CUDA"}
for _, g := range mem {
if !slices.Contains(gpus, g.Index) {
continue
}
used := 0
for _, p := range g.Procs {
if holds([]int{p.PID}, pid, group) {
used += p.UsedMB
}
}
if used > 0 {
c.Devices = append(c.Devices, fmt.Sprintf("GPU %d: %.1f GB", g.Index, float64(used)/1024))
}
}
if len(c.Devices) == 0 {
// Placed on GPUs and holding none of their memory.
c.Backend = "CPU"
}
return c, true
}
+52
View File
@@ -122,3 +122,55 @@ func TestForeignGPUMemoryLeavesOutTheAgentsOwnShards(t *testing.T) {
t.Errorf("no reading came back as %v", foreign)
}
}
func TestLlamaCppsOwnWordOnTheCPUIsEnoughToRefuse(t *testing.T) {
eng := fallbackEngine{}
// Without nvidia-smi to ask, the engine's own output says it.
c, said, err := engineCompute(eng, []int{0}, []string{
"warning: no usable GPU found, --gpu-layers option will be ignored",
"load_tensors: offloaded 0/49 layers to GPU"})
if err == nil || !said || !strings.Contains(err.Error(), "computing on the CPU") || c.Backend != "CPU" {
t.Fatalf("a GPU shard llama.cpp put on the CPU: %v %+v", err, c)
}
c, said, err = engineCompute(eng, []int{0}, []string{
"llama_model_load_from_file_impl: using device CUDA0 (Quadro RTX 6000) - 23800 MiB free",
"load_tensors: offloaded 49/49 layers to GPU"})
if err != nil || !said || c.Backend != "CUDA" || c.Offloaded != 49 || computeToWire(&c).GetLayersOffloaded() != 49 {
t.Errorf("on its GPU: %v %+v", err, c)
}
// A CPU shard is not refused for being on the CPU, and says so.
if c, _, err := engineCompute(eng, nil, []string{"load_tensors: offloaded 0/49 layers to GPU"}); err != nil || c.Backend != "CPU" {
t.Errorf("a CPU shard: %v %+v", err, c)
}
// An engine that said nothing says nothing.
if _, said, err := engineCompute(eng, []int{0}, []string{"listening"}); err != nil || said {
t.Errorf("nothing said: %v", err)
}
}
func TestNvidiaSMISaysWhereAShardComputes(t *testing.T) {
oldMem, oldGroup := gpuMemory, processGroup
t.Cleanup(func() { gpuMemory, processGroup = oldMem, oldGroup })
processGroup = func(pid int) int { return pid }
gpuMemory = func(context.Context, string) ([]discovery.GPUMemory, error) {
return []discovery.GPUMemory{
{Index: 0, UsedMB: 20000, Procs: []discovery.GPUProcess{{PID: 700, UsedMB: 2048}, {PID: 900, UsedMB: 17000}}},
{Index: 1, UsedMB: 0},
}, nil
}
a := &Agent{log: slog.Default()}
if c, ok := a.gpuCompute(context.Background(), []int{0}, 700); !ok || c.Backend != "CUDA" ||
len(c.Devices) != 1 || c.Devices[0] != "GPU 0: 2.0 GB" {
t.Errorf("on GPU 0: %+v %v", c, ok)
}
if c, ok := a.gpuCompute(context.Background(), []int{1}, 700); !ok || c.Backend != "CPU" {
t.Errorf("placed on GPU 1, holding none of it: %+v", c)
}
if c, ok := a.gpuCompute(context.Background(), nil, 700); !ok || c.Backend != "CPU" {
t.Errorf("a CPU shard: %+v", c)
}
gpuMemory = func(context.Context, string) ([]discovery.GPUMemory, error) { return nil, nil }
if _, ok := a.gpuCompute(context.Background(), []int{0}, 700); ok {
t.Error("no nvidia-smi read as an answer")
}
}
+109
View File
@@ -0,0 +1,109 @@
package agent
import (
"context"
"crypto/sha256"
"os"
"path/filepath"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
// The cluster's settings for this agent (BACKLOG E3): what agent.yaml can
// say but the agent's id, control planes, address and certificate, kept by
// the control planes and sent over the stream. Kept here beside agent.yaml,
// which then holds only what the agent needs to start; at start the two are
// read together, these over the file.
// SettingsFile keeps the settings the control plane last sent.
var SettingsFile = "/var/lib/llmgw-agent/agent-settings.yaml"
// ReadSettings is what SettingsFile holds, nil when nothing.
func ReadSettings() []byte {
b, err := os.ReadFile(SettingsFile)
if err != nil {
return nil
}
return b
}
// SetSettingsCheck says how to tell whether settings would load beside this
// agent's own file; settings that would not are not taken.
func (a *Agent) SetSettingsCheck(f func([]byte) error) { a.settingsCheck = f }
// applySettings takes the settings the control plane sent: nothing when they
// are what this agent runs on, and otherwise kept and started again on.
func (a *Agent) applySettings(m *llmgwv1.AgentSettings) {
next := m.GetYaml()
if next == a.cfg.Settings {
return
}
if a.settingsCheck != nil {
if err := a.settingsCheck([]byte(next)); err != nil {
// Kept running on what it has: settings that will not load
// here must not take the node out of service.
a.log.Error("the cluster's settings for this agent do not load beside its agent.yaml; running on what it has",
"err", err)
return
}
}
if err := os.MkdirAll(filepath.Dir(SettingsFile), 0o755); err != nil {
a.log.Error("could not keep the cluster's settings for this agent", "err", err)
return
}
tmp := SettingsFile + ".new"
if err := os.WriteFile(tmp, []byte(next), 0o600); err != nil {
a.log.Error("could not keep the cluster's settings for this agent", "err", err)
return
}
if err := os.Rename(tmp, SettingsFile); err != nil {
a.log.Error("could not keep the cluster's settings for this agent", "err", err)
return
}
a.log.Info("the cluster's settings for this agent changed; starting again on them, the engines keep serving")
time.Sleep(300 * time.Millisecond)
exitForUpgrade()
}
// WatchFile starts the agent again when its configuration file changes and
// still loads: agent.yaml is read only at start, and a change to it that
// waited for somebody to restart the agent was a change nobody saw take
// effect.
func (a *Agent) WatchFile(ctx context.Context, path string, loads func() error) {
if path == "" {
return
}
sum := func() [32]byte {
b, _ := os.ReadFile(path) //nolint:gosec // the agent's own configuration
return sha256.Sum256(b)
}
was := sum()
t := time.NewTicker(watchEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
now := sum()
if now == was {
continue
}
if err := loads(); err != nil {
// Once per change: an editor saving halfway must not stop the
// agent, and a broken file is reported, not restarted into.
a.log.Error("agent.yaml changed and does not load; running on what it had", "path", path, "err", err)
was = now
continue
}
a.log.Info("agent.yaml changed; starting again on it, the engines keep serving", "path", path)
time.Sleep(300 * time.Millisecond)
exitForUpgrade()
return
}
}
// watchEvery is how often agent.yaml is looked at.
var watchEvery = 10 * time.Second
+88
View File
@@ -0,0 +1,88 @@
package agent
import (
"errors"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
func settingsAgent(t *testing.T, running string) (*Agent, chan struct{}) {
t.Helper()
oldFile, oldExit := SettingsFile, exitForUpgrade
SettingsFile = filepath.Join(t.TempDir(), "agent-settings.yaml")
exited := make(chan struct{}, 1)
exitForUpgrade = func() { exited <- struct{}{} }
t.Cleanup(func() { SettingsFile, exitForUpgrade = oldFile, oldExit })
cfg := Config{Server: "127.0.0.1:7443", NodeID: "n1", WeightsCache: t.TempDir(), EnginesDir: t.TempDir(), Settings: running}
cfg.Superv.WorkDir = t.TempDir()
a, err := New(cfg, nil, nil)
if err != nil {
t.Fatal(err)
}
return a, exited
}
// exitedSoon is whether the agent exited within a second: it waits a
// moment for its log line to leave first.
func exitedSoon(ch chan struct{}) bool {
select {
case <-ch:
return true
case <-time.After(time.Second):
return false
}
}
func TestAnAgentOnItsSettingsStaysAndOneOffThemStartsAgain(t *testing.T) {
a, exited := settingsAgent(t, "heartbeat_interval: 5s\n")
a.applySettings(&llmgwv1.AgentSettings{Yaml: "heartbeat_interval: 5s\n"})
if exitedSoon(exited) || ReadSettings() != nil {
t.Error("settings it already runs on restarted it")
}
a.applySettings(&llmgwv1.AgentSettings{Yaml: "heartbeat_interval: 2s\n"})
if !exitedSoon(exited) || string(ReadSettings()) != "heartbeat_interval: 2s\n" {
t.Errorf("new settings: kept %q", ReadSettings())
}
}
func TestSettingsThatWouldNotLoadAreNotTaken(t *testing.T) {
a, exited := settingsAgent(t, "")
a.SetSettingsCheck(func([]byte) error { return errors.New("fabric ib-a lists no HCAs") })
a.applySettings(&llmgwv1.AgentSettings{Yaml: "fabrics: [{name: ib-a}]\n"})
if exitedSoon(exited) || ReadSettings() != nil {
t.Error("settings that do not load were taken")
}
}
func TestAChangedAgentYamlThatLoadsStartsTheAgentAgain(t *testing.T) {
a, exited := settingsAgent(t, "")
old := watchEvery
watchEvery = 10 * time.Millisecond
t.Cleanup(func() { watchEvery = old })
p := filepath.Join(t.TempDir(), "agent.yaml")
_ = os.WriteFile(p, []byte("node_id: n1\n"), 0o600)
var loads atomic.Bool
check := func() error {
if loads.Load() {
return nil
}
return errors.New("broken")
}
go a.WatchFile(t.Context(), p, check)
time.Sleep(30 * time.Millisecond)
// Saved broken: reported, not restarted into.
_ = os.WriteFile(p, []byte("node_id: [\n"), 0o600)
if exitedSoon(exited) {
t.Fatal("a broken agent.yaml restarted the agent")
}
loads.Store(true)
_ = os.WriteFile(p, []byte("node_id: n1\nlabels: {a: b}\n"), 0o600)
if !exitedSoon(exited) {
t.Error("a changed agent.yaml did not restart the agent")
}
}
+38 -2
View File
@@ -113,7 +113,10 @@ type Shard struct {
exitCode int
logs *ringBuffer
cmd *exec.Cmd
// cmdline is an adopted engine's command line, from its state file: it
// has no cmd of this agent's to read it from.
cmdline []string
cmd *exec.Cmd
// sidecars are the shard's supporting processes, torn down with it.
sidecars []*exec.Cmd
cancel context.CancelFunc
@@ -644,6 +647,32 @@ func (s *Supervisor) Get(id string) (*Shard, error) {
return sh, nil
}
// SetCompute records where a shard's engine said it put the model, with the
// shard's state on disk.
func (s *Supervisor) SetCompute(id string, c engines.Compute) {
s.mu.Lock()
sh, ok := s.shards[id]
if ok {
sh.Spec.Compute = &c
}
s.mu.Unlock()
if ok && sh.pid > 0 {
_ = s.writeState(sh)
}
}
// ComputeOf is where a shard's engine was found to compute, nil when not
// yet known.
func (s *Supervisor) ComputeOf(id string) *engines.Compute {
s.mu.Lock()
defer s.mu.Unlock()
if sh, ok := s.shards[id]; ok && sh.Spec.Compute != nil {
c := *sh.Spec.Compute
return &c
}
return nil
}
// Remove forgets a terminated shard.
func (s *Supervisor) Remove(id string) {
s.mu.Lock()
@@ -703,11 +732,17 @@ type shardState struct {
// writeState records everything needed to recognise and describe the process later.
func (s *Supervisor) writeState(sh *Shard) error {
// An adopted engine was started by an earlier agent: its command line
// is the one its state file kept.
cmdline := sh.cmdline
if sh.cmd != nil {
cmdline = sh.cmd.Args
}
b, err := json.Marshal(shardState{
ShardID: sh.ID,
ReplicaID: sh.Spec.ReplicaID,
PID: sh.pid,
Cmdline: sh.cmd.Args,
Cmdline: cmdline,
StartedAt: sh.started,
Spec: sh.Spec,
})
@@ -804,6 +839,7 @@ func (s *Supervisor) Adopt(o Orphan) (*Shard, error) {
started: o.StartedAt,
done: make(chan struct{}),
adopted: true,
cmdline: o.Cmdline,
}
s.shards[o.ShardID] = sh
s.mu.Unlock()
@@ -848,3 +848,60 @@ func eventually(t *testing.T, limit time.Duration, msg string, cond func() bool)
time.Sleep(10 * time.Millisecond)
}
}
func TestWhatAShardComputesOnSurvivesTheAgent(t *testing.T) {
s, _ := newSupervisor(t)
spec := engines.ShardSpec{ReplicaID: "rep-1", ShardID: "a", HTTPPort: 20100}
sh, err := s.Start(t.Context(), "a", spec, shellCommand("sleep 30"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s.Stop(context.Background(), "a") })
_ = sh
s.SetCompute("a", engines.Compute{Backend: "CUDA", Offloaded: 49, Total: 49})
s2, err := New(Config{WorkDir: s.cfg.WorkDir, StopTimeout: 2 * time.Second},
&scriptedHealth{}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
orphans, err := s2.FindOrphans()
if err != nil || len(orphans) != 1 {
t.Fatalf("%v %d", err, len(orphans))
}
if c := orphans[0].Spec.Compute; c == nil || c.Backend != "CUDA" || c.Offloaded != 49 {
t.Errorf("not kept: %+v", c)
}
}
func TestAnAdoptedShardsComputeIsKeptWithoutCrashing(t *testing.T) {
s, _ := newSupervisor(t)
spec := engines.ShardSpec{ReplicaID: "rep-1", ShardID: "a", HTTPPort: 20100}
if _, err := s.Start(t.Context(), "a", spec, shellCommand("sleep 30")); err != nil {
t.Fatal(err)
}
// An agent restart: the engine is adopted by a new supervisor, which did
// not start it and has no command of its own for it.
s2, err := New(Config{WorkDir: s.cfg.WorkDir, StopTimeout: 2 * time.Second},
&scriptedHealth{}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
orphans, _ := s2.FindOrphans()
if len(orphans) != 1 {
t.Fatalf("%d orphans", len(orphans))
}
if _, err := s2.Adopt(orphans[0]); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = s2.Stop(context.Background(), "a") })
s2.SetCompute("a", engines.Compute{Backend: "CPU"})
// And a third agent still finds it, with its command line and compute.
s3, _ := New(Config{WorkDir: s.cfg.WorkDir, StopTimeout: 2 * time.Second},
&scriptedHealth{}, slog.New(slog.DiscardHandler))
again, _ := s3.FindOrphans()
if len(again) != 1 || len(again[0].Cmdline) == 0 || again[0].Spec.Compute == nil {
t.Fatalf("lost: %+v", again)
}
}
+49 -1
View File
@@ -10,6 +10,7 @@ import (
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
)
type alwaysReady struct{}
@@ -40,7 +41,7 @@ func TestAnAdoptedEngineThatDiesIsReported(t *testing.T) {
out := make(chan *llmgwv1.AgentMessage, 4)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go a.watchExits(ctx, out)
go a.watchExits(ctx, out, nil)
time.Sleep(1500 * time.Millisecond) // seen ready
_ = cmd.Process.Kill()
@@ -54,3 +55,50 @@ func TestAnAdoptedEngineThatDiesIsReported(t *testing.T) {
t.Fatal("the adopted engine died and nothing was reported")
}
}
// Registered while its health check still ran: the ready that follows is
// reported, or the control plane keeps the replica starting while it serves.
func TestAShardRegisteredStartingIsReportedReady(t *testing.T) {
sup, err := supervisor.New(supervisor.Config{WorkDir: t.TempDir(), RunAs: "-"}, alwaysReady{}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
cmd := exec.Command("sleep", "60")
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
defer func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }()
sh, err := sup.Adopt(supervisor.Orphan{ShardID: "m-r0", ReplicaID: "m", PID: cmd.Process.Pid,
Spec: engines.ShardSpec{ReplicaID: "m", ShardID: "m-r0", HTTPPort: 1}, Alive: true})
if err != nil {
t.Fatal(err)
}
a := &Agent{sup: sup, log: slog.New(slog.DiscardHandler)}
pending := unready(a.runningShards())
if !pending["m-r0"] {
t.Fatalf("registration reported it ready already: %v", pending)
}
out := make(chan *llmgwv1.AgentMessage, 4)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
go a.watchExits(ctx, out, pending)
if err := sup.WaitReady(t.Context(), sh, "http://127.0.0.1:1", "m", 5*time.Second); err != nil {
t.Fatal(err)
}
select {
case m := <-out:
s := m.GetShard()
if s.GetShardId() != "m-r0" || s.GetState() != agentsvc.StateToWire(supervisor.Ready) {
t.Errorf("reported %+v", s)
}
case <-time.After(10 * time.Second):
t.Fatal("it became ready and nothing was reported")
}
select {
case m := <-out:
t.Errorf("reported twice: %+v", m.GetShard())
case <-time.After(2500 * time.Millisecond):
}
}
+7 -1
View File
@@ -12,6 +12,7 @@ import (
"net/http"
"net/url"
"regexp"
"slices"
"sort"
"strings"
"time"
@@ -110,11 +111,16 @@ func (w *statusWriter) Flush() {
}
}
// notChanges are POSTs that change nothing and carry text that must not be
// kept: asking a judge about a sentence.
var notChanges = []string{"/api/v1/guard/judges/ask"}
// API records every change to the admin API: a request that is not a GET or
// HEAD, under /api/. actor says who made it.
func API(sink Sink, actor func(*http.Request) string, log *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet || r.Method == http.MethodHead || !strings.HasPrefix(r.URL.Path, "/api/") {
if r.Method == http.MethodGet || r.Method == http.MethodHead || !strings.HasPrefix(r.URL.Path, "/api/") ||
slices.Contains(notChanges, r.URL.Path) {
next.ServeHTTP(w, r)
return
}
+10
View File
@@ -72,3 +72,13 @@ func TestABodyLargerThanWhatIsRecordedReachesTheHandlerWhole(t *testing.T) {
t.Errorf("the handler was given %d of %d bytes", got, len(big))
}
}
func TestAskingAJudgeKeepsNoText(t *testing.T) {
sink := &memSink{}
h := API(sink, func(*http.Request) string { return "ilker" }, nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("POST", "/api/v1/guard/judges/ask",
strings.NewReader(`{"text":"something a user wrote"}`)))
if len(sink.got) != 0 {
t.Errorf("a judge's question was recorded: %+v", sink.got)
}
}
+22 -1
View File
@@ -40,10 +40,23 @@ func newAgentCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, _ []string) error {
log := logger()
cfg, err := config.LoadAgent(configPath)
// The cluster's settings for this agent over its file, as the
// control plane last sent them. Settings that no longer load
// beside the file are set aside rather than keeping the agent
// from starting: the control plane sends them again, and says
// why they are refused.
settings := agent.ReadSettings()
cfg, err := config.LoadAgentWith(configPath, settings)
if err != nil && settings != nil {
log.Warn("the cluster's settings kept for this agent do not load beside its agent.yaml; starting without them",
"err", err)
settings = nil
cfg, err = config.LoadAgent(configPath)
}
if err != nil {
return err
}
cfg.Settings = string(settings)
if server != "" {
cfg.Server = server
}
@@ -89,9 +102,17 @@ func newAgentCmd() *cobra.Command {
if err != nil {
return err
}
a.SetSettingsCheck(func(b []byte) error {
_, err := config.LoadAgentWith(configPath, b)
return err
})
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go a.WatchFile(ctx, configPath, func() error {
_, err := config.LoadAgentWith(configPath, agent.ReadSettings())
return err
})
log.Info("agent starting",
"node", cfg.NodeID, "server", cfg.Server,
+1 -1
View File
@@ -54,7 +54,7 @@ func newBackupCmd() *cobra.Command {
"the newest archive as llmgw_backup_last_success_timestamp_seconds, and\n" +
"BackupTooOld fires when it is more than a day old.",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := config.Load(configPath)
cfg, err := loadEffectiveConfig(configPath)
if err != nil {
return err
}
+1 -1
View File
@@ -323,7 +323,7 @@ func loadServerConfig(path string) (config.Server, bool) {
if _, err := os.Stat(path); err != nil {
return config.Server{}, false
}
cfg, err := config.Load(path)
cfg, err := loadEffectiveConfig(path)
if err != nil {
return config.Server{}, false
}
+367
View File
@@ -0,0 +1,367 @@
package cli
import (
"context"
"crypto/sha256"
"errors"
"fmt"
"log/slog"
"os"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// settingsPoll is how often a control plane asks whether the cluster's
// settings, or its own server.yaml, changed.
const settingsPoll = 5 * time.Second
// clusterSettings is what a control plane runs on, and where it came from:
// its server.yaml for its own keys, the store's settings for the rest.
type clusterSettings struct {
cfg config.Server
eff map[string]any // the settings, as written, cfg was made from
cluster map[string]any // the store's document, nil without a store
version int64
fileHash [32]byte
}
// loadClusterSettings reads the configuration a control plane starts on.
// Without a store it is the file. With one, the store's settings take the
// place of the file's cluster keys; a store with none yet is given the
// file's, so the first control plane to start against it brings them in.
func loadClusterSettings(ctx context.Context, path string, log *slog.Logger) (clusterSettings, error) {
var s clusterSettings
b, file, err := readSettingsFile(path)
if err != nil {
return s, err
}
s.fileHash = sha256.Sum256(b)
fileCfg, err := config.FromMap(file)
if err != nil {
return s, fileError(path, err)
}
if fileCfg.Auth.Store == "" {
s.cfg, s.eff = fileCfg, file
return s, nil
}
st, err := storepkg.Open(ctx, fileCfg.Auth.Store)
if err != nil {
return s, fmt.Errorf("the store, for the cluster's settings: %w", err)
}
defer st.Close()
doc, err := st.ClusterSettings(ctx)
if err != nil {
return s, fmt.Errorf("reading the cluster's settings: %w", err)
}
if doc.Version == 0 {
if doc, err = importSettings(ctx, st, path, file, log); err != nil {
return s, err
}
} else if ignored := config.Ignored(file); len(ignored) > 0 {
log.Warn("server.yaml sets keys the cluster's settings now hold; they are not read "+
"(llmgw ctl config shows what is in force)", "keys", strings.Join(ignored, ", "))
}
// The newest version that this machine can run on. One that cannot — a
// path only another control plane has — must not keep this one down,
// where nothing could be used to put it right.
versions := []storepkg.SettingsVersion{doc}
if older, err := st.ClusterSettingsHistory(ctx, 20); err == nil && len(older) > 1 {
versions = append(versions, older[1:]...)
}
var firstErr error
for _, v := range versions {
cluster, err := config.Parse([]byte(v.Body))
if err == nil {
eff := config.Layer(file, cluster)
var cfg config.Server
if cfg, err = config.FromMap(eff); err == nil {
if v.Version != doc.Version {
log.Error("the cluster's newest settings do not load on this machine; running on an older version",
"newest", doc.Version, "running", v.Version, "err", firstErr)
}
s.cfg, s.eff, s.cluster, s.version = cfg, eff, cluster, doc.Version
return s, nil
}
}
if firstErr == nil {
firstErr = err
}
}
return s, fmt.Errorf("the cluster's settings (version %d) do not load on this machine: %w", doc.Version, firstErr)
}
// importSettings records a server.yaml's cluster keys as the cluster's first
// settings, with the baseline guard policy when the file has none: a cluster
// installed before guardrails gets them on its first start after an upgrade.
func importSettings(ctx context.Context, st *storepkg.DB, path string, file map[string]any,
log *slog.Logger,
) (storepkg.SettingsVersion, error) {
_, cluster := config.Split(file)
note := "imported from " + path
if _, ok := cluster["guard"]; !ok {
baseline, err := config.Parse([]byte(guardSection(false)))
if err == nil {
cluster["guard"] = baseline["guard"]
note += ", with the baseline guard policy it did not have"
}
}
body, err := config.Marshal(cluster)
if err != nil {
return storepkg.SettingsVersion{}, err
}
host, _ := os.Hostname()
_, err = st.PutClusterSettings(ctx, 0, string(body), host, note)
switch {
case errors.Is(err, storepkg.ErrSettingsChanged):
// Another control plane brought its own in first: those are the
// cluster's.
case err != nil:
return storepkg.SettingsVersion{}, fmt.Errorf("recording the cluster's settings: %w", err)
default:
log.Info("the cluster's settings are now kept in the store; server.yaml keeps this machine's own",
"from", path)
}
return st.ClusterSettings(ctx)
}
func readSettingsFile(path string) ([]byte, map[string]any, error) {
if path == "" {
return nil, map[string]any{}, nil
}
b, err := os.ReadFile(path) //nolint:gosec // the configuration
if err != nil {
return nil, nil, fmt.Errorf("config file: %w", err)
}
m, err := config.Parse(b)
if err != nil {
return nil, nil, fmt.Errorf("parsing %s: %w", path, err)
}
return b, m, nil
}
func fileError(path string, err error) error {
if path == "" {
return err
}
return fmt.Errorf("%s: %w", path, err)
}
// settingsWatch follows the cluster's settings and this machine's
// server.yaml, and puts every change in force.
type settingsWatch struct {
path string
st *storepkg.DB // nil: the file only
cur clusterSettings
// live applies a change without a restart, reporting whether it could.
live func(cfg config.Server, changed []string) bool
// restart has the process start again on the new settings.
restart func(reason string)
log *slog.Logger
}
func (w *settingsWatch) run(ctx context.Context) {
t := time.NewTicker(settingsPoll)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
w.check(ctx)
}
}
}
// check reads what changed, if anything, and applies it.
func (w *settingsWatch) check(ctx context.Context) {
version := w.cur.version
if w.st != nil {
v, err := w.st.ClusterSettingsVersion(ctx)
if err != nil {
return // asked again in a few seconds
}
version = v
}
b, file, err := readSettingsFile(w.path)
if err != nil {
w.log.Error("server.yaml cannot be read; running on what it said before", "err", err)
return
}
hash := sha256.Sum256(b)
if version == w.cur.version && hash == w.cur.fileHash {
return
}
cluster := w.cur.cluster
eff := file
if w.st != nil {
if version != w.cur.version {
doc, err := w.st.ClusterSettings(ctx)
if err != nil {
return
}
if cluster, err = config.Parse([]byte(doc.Body)); err != nil {
w.log.Error("the cluster's settings do not parse; running on what was in force",
"version", doc.Version, "err", err)
w.cur.version, w.cur.fileHash = version, hash
return
}
}
eff = config.Layer(file, cluster)
}
cfg, err := config.FromMap(eff)
if err != nil {
w.log.Error("the changed settings do not load on this machine; running on what was in force",
"version", version, "err", err)
w.cur.version, w.cur.fileHash = version, hash
return
}
changed := config.Changed(w.cur.eff, eff)
w.cur = clusterSettings{cfg: cfg, eff: eff, cluster: cluster, version: version, fileHash: hash}
if len(changed) == 0 {
return
}
if w.live != nil && w.live(cfg, changed) {
w.log.Info("settings changed and in force", "version", version, "changed", strings.Join(changed, ", "))
return
}
w.restart(fmt.Sprintf("settings version %d changed %s", version, strings.Join(changed, ", ")))
}
// liveSettings are the keys a control plane puts in force without starting
// again: what it reads at every use, or not at all.
var liveSettings = []string{
"guard",
// Stopping and starting the service (llmgw stop, llmgw start).
"serving",
// Sent to the nodes and this machine's firewall unit as they change.
"firewall", "firewall_networks",
// Sent to the nodes' agents as they change.
"agents",
// Read by server init, node add and backups, not by the server.
"backup_recipients", "backup_copy_to",
}
// onlyLive is whether every changed key is one of liveSettings.
func onlyLive(changed []string) bool {
for _, c := range changed {
top, _, _ := strings.Cut(c, ".")
if !slices.Contains(liveSettings, top) {
return false
}
}
return true
}
// checkClusterSettings refuses a settings document that would not load,
// laid over the server.yaml at path, or that sets one machine's keys.
func checkClusterSettings(path string, body []byte) error {
cluster, err := config.Parse(body)
if err != nil {
return err
}
if err := config.CheckCluster(cluster); err != nil {
return err
}
_, file, err := readSettingsFile(path)
if err != nil {
return err
}
_, err = config.FromMap(config.Layer(file, cluster))
return err
}
// loadEffectiveConfig is what the control plane on this machine runs on:
// its server.yaml with the cluster's settings from the store laid over it,
// for the commands beside it that read cluster keys — init, node add,
// backups. The file alone when the store cannot be reached or holds none.
func loadEffectiveConfig(path string) (config.Server, error) {
_, file, err := readSettingsFile(path)
if err != nil {
return config.Default(), err
}
fileCfg, err := config.FromMap(file)
if err != nil {
return fileCfg, fileError(path, err)
}
cluster, ok := storedSettings(fileCfg)
if !ok {
return fileCfg, nil
}
cfg, err := config.FromMap(config.Layer(file, cluster))
if err != nil {
return fileCfg, nil //nolint:nilerr // the file still says what this machine is
}
return cfg, nil
}
// storedSettings are the cluster's settings, when the store a configuration
// names can be reached and holds some.
func storedSettings(cfg config.Server) (map[string]any, bool) {
if cfg.Auth.Store == "" {
return nil, false
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
st, err := storepkg.Open(ctx, cfg.Auth.Store)
if err != nil {
return nil, false
}
defer st.Close()
doc, err := st.ClusterSettings(ctx)
if err != nil || doc.Version == 0 {
return nil, false
}
cluster, err := config.Parse([]byte(doc.Body))
return cluster, err == nil
}
// changeStoredSettings changes the cluster's settings from a command run on
// a control plane (init), when the store holds them: changed is false when
// it does not, and the file is then where the change belongs.
func changeStoredSettings(path, note string, change func(doc *settings.Doc) error) (changed bool, err error) {
_, file, err := readSettingsFile(path)
if err != nil {
return false, err
}
fileCfg, err := config.FromMap(file)
if err != nil || fileCfg.Auth.Store == "" {
return false, nil //nolint:nilerr // no store to keep them in
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
st, err := storepkg.Open(ctx, fileCfg.Auth.Store)
if err != nil {
return false, nil //nolint:nilerr // not set up yet: the file it is
}
defer st.Close()
cur, err := st.ClusterSettings(ctx)
if err != nil || cur.Version == 0 {
return false, err
}
doc, err := settings.FromBytes("the cluster's settings", []byte(cur.Body))
if err != nil {
return false, err
}
if err := change(doc); err != nil {
return false, err
}
body, err := doc.Bytes()
if err != nil {
return false, err
}
if err := checkClusterSettings(path, body); err != nil {
return false, err
}
who := "llmgw"
if u := os.Getenv("SUDO_USER"); u != "" {
who = u
}
_, err = st.PutClusterSettings(ctx, cur.Version, string(body), who, note)
return err == nil, err
}
+139
View File
@@ -0,0 +1,139 @@
package cli
import (
"context"
"io"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func settingsFixture(t *testing.T, extra string) (path, store string) {
t.Helper()
dir := t.TempDir()
store = filepath.Join(dir, "llmgw.db")
path = filepath.Join(dir, "server.yaml")
body := "listen: \":8080\"\nagent_insecure: true\nauth:\n enabled: true\n store: " + store +
"\nrouter:\n retries: 2\n" + extra
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
return path, store
}
var quiet = slog.New(slog.NewTextHandler(io.Discard, nil))
func TestTheFirstStartBringsTheFilesSettingsInWithGuardrails(t *testing.T) {
path, store := settingsFixture(t, "")
s, err := loadClusterSettings(t.Context(), path, quiet)
if err != nil {
t.Fatal(err)
}
if s.version != 1 || s.cfg.Router.Retries != 2 {
t.Errorf("version %d retries %d", s.version, s.cfg.Router.Retries)
}
if len(s.cfg.Guard.Policies) == 0 || s.cfg.Guard.Policies["baseline"].Judge != nil {
t.Errorf("a file without guard got %+v, want the baseline without a judge", s.cfg.Guard)
}
st, _ := storepkg.Open(t.Context(), store)
defer st.Close()
doc, _ := st.ClusterSettings(t.Context())
if strings.Contains(doc.Body, "listen") || strings.Contains(doc.Body, store) || !strings.Contains(doc.Body, "retries: 2") {
t.Errorf("the store holds a machine's keys, or lost the cluster's:\n%s", doc.Body)
}
// From then on the store's settings are the cluster's, whatever a
// file says.
_ = os.WriteFile(path, []byte(strings.Replace(mustRead(t, path), "retries: 2", "retries: 9", 1)), 0o600)
again, err := loadClusterSettings(t.Context(), path, quiet)
if err != nil || again.version != 1 || again.cfg.Router.Retries != 2 {
t.Errorf("second start: version %d retries %d err %v", again.version, again.cfg.Router.Retries, err)
}
}
func mustRead(t *testing.T, p string) string {
b, err := os.ReadFile(p)
if err != nil {
t.Fatal(err)
}
return string(b)
}
func TestEveryChangeIsAppliedLiveOrByStartingAgain(t *testing.T) {
path, store := settingsFixture(t, "")
s, err := loadClusterSettings(t.Context(), path, quiet)
if err != nil {
t.Fatal(err)
}
st, _ := storepkg.Open(t.Context(), store)
defer st.Close()
var lived [][]string
var restarts []string
w := &settingsWatch{path: path, st: st, cur: s, log: quiet,
live: func(_ config.Server, changed []string) bool {
lived = append(lived, changed)
return onlyLive(changed)
},
restart: func(why string) { restarts = append(restarts, why) },
}
ctx := context.Background()
put := func(body string) {
t.Helper()
v, _ := st.ClusterSettingsVersion(ctx)
if _, err := st.PutClusterSettings(ctx, v, body, "test", ""); err != nil {
t.Fatal(err)
}
}
cur, _ := st.ClusterSettings(ctx)
w.check(ctx) // nothing changed
if len(lived)+len(restarts) != 0 {
t.Fatalf("acted on no change: %v %v", lived, restarts)
}
put(strings.Replace(cur.Body, "injection: flag", "injection: block", 1))
w.check(ctx)
if len(restarts) != 0 || len(lived) != 1 || lived[0][0] != "guard" {
t.Fatalf("a guard change: lived %v restarts %v", lived, restarts)
}
cur, _ = st.ClusterSettings(ctx)
put(strings.Replace(cur.Body, "retries: 2", "retries: 4", 1))
w.check(ctx)
if len(restarts) != 1 || !strings.Contains(restarts[0], "router") || w.cur.cfg.Router.Retries != 4 {
t.Fatalf("a router change: restarts %v retries %d", restarts, w.cur.cfg.Router.Retries)
}
// A machine's own key, in its file.
_ = os.WriteFile(path, []byte(strings.Replace(mustRead(t, path), `":8080"`, `":8090"`, 1)), 0o600)
w.check(ctx)
if len(restarts) != 2 || !strings.Contains(restarts[1], "listen") {
t.Fatalf("a listener change: %v", restarts)
}
// Settings that do not load are not put in force.
cur, _ = st.ClusterSettings(ctx)
put(cur.Body + "control:\n timezone: Mars/Olympus\n")
w.check(ctx)
if len(restarts) != 2 || w.cur.cfg.Control.Timezone != "" {
t.Errorf("bad settings were applied: %v %q", restarts, w.cur.cfg.Control.Timezone)
}
}
func TestAClusterDocumentCannotSetAMachinesKeys(t *testing.T) {
path, _ := settingsFixture(t, "")
if err := checkClusterSettings(path, []byte("listen: \":1\"\n")); err == nil {
t.Error("listen was accepted")
}
if err := checkClusterSettings(path, []byte("control:\n timezone: Mars/Olympus\n")); err == nil {
t.Error("a timezone that is not one was accepted")
}
if err := checkClusterSettings(path, []byte("router:\n retries: 3\n")); err != nil {
t.Error(err)
}
}
+65 -7
View File
@@ -39,6 +39,14 @@ func newCtlClient(addr string, tokenFiles ...string) *ctlClient {
}
}
// patient is the same client waiting up to d for an answer, for a call the
// server itself may spend minutes on.
func (c *ctlClient) patient(d time.Duration) *ctlClient {
cp := *c
cp.http = &http.Client{Timeout: d}
return &cp
}
func (c *ctlClient) get(ctx context.Context, path string, out any) error {
return c.do(ctx, http.MethodGet, path, nil, out)
}
@@ -108,6 +116,13 @@ func (c *ctlClient) do(ctx context.Context, method, path string, body, out any)
if out == nil {
return nil
}
// An older control plane answers a path it does not know with its
// dashboard: a page, which read as JSON said only "invalid character '<'".
if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
return fmt.Errorf("%s %s answered with a page, not the API: the control plane answering "+
"(the leader, through a standby) does not know it — is it on an older release? "+
"llmgw ctl status says which it runs", method, path)
}
return json.NewDecoder(resp.Body).Decode(out)
}
@@ -160,11 +175,13 @@ func newCtlCmd() *cobra.Command {
newJobsCmd(client, &asJSON),
newEngineCheckCmd(),
newEngineBuildCmd(client),
newToolCheckCmd(),
newToolCheckCmd(client),
newWeightsCmd(client, &asJSON),
newCapacityCmd(client, &asJSON),
newQuotaCmd(client, &asJSON),
newGuardCmd(client, &asJSON),
newKBCmd(client, &asJSON),
newConfigCmd(client, &asJSON),
newAuditCmd(client, &asJSON),
newNotifyCmd(client, &asJSON),
newUsageCmd(client, &asJSON),
@@ -216,8 +233,8 @@ func newNodesCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
// What the node can run, which is the first thing to look
// at when a model will not place and the hardware looks
// fine.
join(n.Engines),
classesColumn(n), join(n.Fabrics), n.Replicas, n.LastSeenAgo)
enginesColumn(n),
classesColumn(n), fabricsColumn(n), n.Replicas, n.LastSeenAgo)
}
_ = w.Flush()
@@ -434,7 +451,7 @@ func newReplicasCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
}
w := tab()
fmt.Fprintln(w, "REPLICA\tMODEL\tSTATE\tPOOL\tNODES\tAGE\tIDLE\tENDPOINT")
fmt.Fprintln(w, "REPLICA\tMODEL\tSTATE\tPOOL\tON\tNODES\tAGE\tIDLE\tENDPOINT")
for _, r := range reps {
var nodes []string
for _, s := range r.Shards {
@@ -452,8 +469,14 @@ func newReplicasCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
if r.Restart != "" {
state += " (restart " + r.Restart + ")"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
r.ID, r.Model, state, r.Pool, join(nodes),
// What the engine computes on, by its own account: the first
// shard's, which serves.
on := "-"
if len(r.Shards) > 0 {
on = r.Shards[0].Compute.Short()
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
r.ID, r.Model, state, r.Pool, on, join(nodes),
(time.Duration(r.AgeSec) * time.Second).String(),
idleColumn(r.IdleSec), r.Endpoint)
// A line rather than a column, because most replicas are not
@@ -463,7 +486,7 @@ func newReplicasCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
if s.Fetch == nil {
continue
}
fmt.Fprintf(w, " %s: fetching %s\t\t\t\t\t\t\t\n",
fmt.Fprintf(w, " %s: fetching %s\t\t\t\t\t\t\t\t\n",
s.Node, fetchColumn(s.Fetch))
}
}
@@ -574,7 +597,42 @@ func emitJSON(v any) error {
// more than one or MIG instances among them: a card cut into two 3g.40gb
// instances beside a whole card is "a100-80g-mig-3g.40gb×2,a100-80g×1", where
// the GPUS column's 3 alone would read as three of something.
// fabricsColumn is the node's fabrics, and when they are not all up, which
// adapters are down: what keeps it out of replicas spanning nodes.
func fabricsColumn(n adminapi.NodeView) string {
s := join(n.Fabrics)
if n.MultiNode != "" && n.MultiNode != "ready" {
s += " (" + n.MultiNode + ")"
}
return s
}
// enginesColumn is each engine with its version: llamacpp b10991.
func enginesColumn(n adminapi.NodeView) string {
if len(n.EngineVersions) == 0 {
return join(n.Engines)
}
parts := make([]string, 0, len(n.Engines))
for _, e := range n.Engines {
if v := n.EngineVersions[e]; v != "" && v != "unknown" {
e += " " + v
}
parts = append(parts, e)
}
return strings.Join(parts, ",")
}
// classesColumn is the node's GPU classes, with their compute capability
// when they report one: 6000-24g (7.5).
func classesColumn(n adminapi.NodeView) string {
s := classesOnly(n)
if len(n.ComputeCapabilities) > 0 && s != "-" {
s += " (" + strings.Join(n.ComputeCapabilities, ",") + ")"
}
return s
}
func classesOnly(n adminapi.NodeView) string {
counted := len(n.ClassGPUs) > 1
for _, c := range n.ClassGPUs {
counted = counted || c.MIG
+377
View File
@@ -0,0 +1,377 @@
package cli
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
)
func newConfigCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
var version int64
c := &cobra.Command{
Use: "config",
Short: "The cluster's settings: show, change, and go back",
Long: "Everything in server.yaml that is not one machine's own — guardrails,\n" +
"limits, pricing, routing, engines, notifications, the mirror's policy —\n" +
"is the cluster's, kept in the store and the same on every control plane.\n" +
"A change is checked, kept as a new version, and in force on every control\n" +
"plane within seconds: live where it can be (guard), otherwise each control\n" +
"plane starts again on it, standbys first and the leader last.\n\n" +
"With no subcommand, prints the settings in force.\n\n" +
"A machine's own keys (" + strings.Join(config.LocalKeys(), ", ") + ")\n" +
"stay in its /etc/llmgw/server.yaml; a change there is noticed and applied\n" +
"on that machine the same way.",
Example: " llmgw ctl config\n" +
" llmgw ctl config get router.retries\n" +
" llmgw ctl config set router.retries 3 --note 'flaky upstream'\n" +
" llmgw ctl config set firewall_networks '[10.20.0.0/16]'\n" +
" llmgw ctl config edit\n" +
" llmgw ctl config history\n" +
" llmgw ctl config rollback 4",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
path := "/api/v1/settings"
if version > 0 {
path += "?version=" + strconv.FormatInt(version, 10)
}
var v adminapi.SettingsView
if err := client().get(cmd.Context(), path, &v); err != nil {
return err
}
if *asJSON {
return emitJSON(v)
}
fmt.Fprintf(cmd.ErrOrStderr(), "# version %d, %s by %s%s\n", v.Version, v.At.Local().Format(time.DateTime),
orUnknown(v.By), noteSuffix(v.Note))
fmt.Fprint(cmd.OutOrStdout(), v.Body)
return nil
},
}
c.Flags().Int64Var(&version, "version", 0, "an earlier version")
c.AddCommand(newConfigGetCmd(client), newConfigSetCmd(client), newConfigUnsetCmd(client),
newConfigEditCmd(client), newConfigHistoryCmd(client, asJSON), newConfigRollbackCmd(client))
return c
}
func orUnknown(s string) string {
if s == "" {
return "unknown"
}
return s
}
func noteSuffix(n string) string {
if n == "" {
return ""
}
return ": " + n
}
// keyPath reads a dotted key, refusing one machine's own.
func keyPath(key string) ([]string, error) {
path := strings.Split(key, ".")
if config.IsLocal(path) {
return nil, fmt.Errorf("%s is each machine's own: change it in that machine's /etc/llmgw/server.yaml, "+
"where a change is applied by itself too", key)
}
return path, nil
}
func newConfigGetCmd(client func() *ctlClient) *cobra.Command {
return &cobra.Command{
Use: "get KEY",
Short: "One key of the cluster's settings, as YAML",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path, err := keyPath(args[0])
if err != nil {
return err
}
var v adminapi.SettingsView
if err := client().get(cmd.Context(), "/api/v1/settings", &v); err != nil {
return err
}
doc, err := settings.FromBytes("settings", []byte(v.Body))
if err != nil {
return err
}
n := doc.Node(path)
if n == nil {
return fmt.Errorf("%s is not set in the cluster's settings; its default applies (docs/reference.md)", args[0])
}
sub, err := settings.FromBytes("value", nil)
if err != nil {
return err
}
if err := sub.SetNode([]string{"v"}, n); err != nil {
return err
}
b, err := sub.Bytes()
if err != nil {
return err
}
out := strings.TrimPrefix(string(b), "v:")
fmt.Fprintln(cmd.OutOrStdout(), strings.TrimSpace(dedent(out)))
return nil
},
}
}
// dedent takes off the two spaces a block nested under one key carries.
func dedent(s string) string {
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = strings.TrimPrefix(l, " ")
}
return strings.Join(lines, "\n")
}
// changeSettings reads the newest settings, changes them, and puts the
// result in force, saying what that does.
func changeSettings(cmd *cobra.Command, client func() *ctlClient, note string,
change func(doc *settings.Doc) error,
) error {
ctx := cmd.Context()
var cur adminapi.SettingsView
if err := client().get(ctx, "/api/v1/settings", &cur); err != nil {
return err
}
doc, err := settings.FromBytes("settings", []byte(cur.Body))
if err != nil {
return err
}
if err := change(doc); err != nil {
return err
}
b, err := doc.Bytes()
if err != nil {
return err
}
return putSettings(cmd, client, cur, b, note)
}
func putSettings(cmd *cobra.Command, client func() *ctlClient, cur adminapi.SettingsView, body []byte, note string) error {
before, _ := config.Parse([]byte(cur.Body))
after, err := config.Parse(body)
if err != nil {
return err
}
changed := config.Changed(before, after)
if len(changed) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "nothing changed")
return nil
}
req, _ := json.Marshal(adminapi.SettingsRequest{Base: cur.Version, Body: string(body), Note: note})
var v adminapi.SettingsView
if err := client().put(cmd.Context(), "/api/v1/settings", req, &v); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "version %d: %s\n", v.Version, strings.Join(changed, ", "))
if onlyLive(changed) {
fmt.Fprintln(cmd.OutOrStdout(), "in force on every control plane within seconds")
} else {
fmt.Fprintln(cmd.OutOrStdout(), "every control plane starts again on it within seconds, standbys first, "+
"the leader half a minute later; engines keep serving")
}
return nil
}
func newConfigSetCmd(client func() *ctlClient) *cobra.Command {
var note string
c := &cobra.Command{
Use: "set KEY VALUE",
Short: "Change one key of the cluster's settings; VALUE is YAML",
Example: " llmgw ctl config set router.retries 3\n" +
" llmgw ctl config set guard.default '[baseline, finance]'\n" +
" llmgw ctl config set notify.webhooks '[{url: https://hooks.example/llmgw}]'",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
path, err := keyPath(args[0])
if err != nil {
return err
}
return changeSettings(cmd, client, note, func(doc *settings.Doc) error {
return doc.SetYAML(path, args[1])
})
},
}
c.Flags().StringVar(&note, "note", "", "why, kept with the version")
return c
}
func newConfigUnsetCmd(client func() *ctlClient) *cobra.Command {
var note string
c := &cobra.Command{
Use: "unset KEY",
Short: "Remove one key from the cluster's settings, so its default applies",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
path, err := keyPath(args[0])
if err != nil {
return err
}
return changeSettings(cmd, client, note, func(doc *settings.Doc) error {
if doc.Node(path) == nil {
return fmt.Errorf("%s is not set", args[0])
}
doc.Unset(path)
return nil
})
},
}
c.Flags().StringVar(&note, "note", "", "why, kept with the version")
return c
}
func newConfigEditCmd(client func() *ctlClient) *cobra.Command {
var note string
c := &cobra.Command{
Use: "edit",
Short: "Edit the cluster's settings in $EDITOR",
Long: "Opens the settings in force in $EDITOR (vi when unset). Saved, they are\n" +
"checked and kept as a new version; a document that does not load is\n" +
"opened again with the reason, until it does or is left unchanged.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var cur adminapi.SettingsView
if err := client().get(cmd.Context(), "/api/v1/settings", &cur); err != nil {
return err
}
dir, err := os.MkdirTemp("", "llmgw-settings")
if err != nil {
return err
}
defer os.RemoveAll(dir)
file := filepath.Join(dir, "settings.yaml")
body := []byte(cur.Body)
problem := ""
for {
text := body
if problem != "" {
text = append([]byte("# NOT SAVED: "+strings.ReplaceAll(problem, "\n", " ")+"\n"), stripProblem(body)...)
}
if err := os.WriteFile(file, text, 0o600); err != nil {
return err
}
if err := runEditor(file, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr()); err != nil {
return err
}
edited, err := os.ReadFile(file) //nolint:gosec // written above
if err != nil {
return err
}
edited = stripProblem(edited)
if bytes.Equal(bytes.TrimSpace(edited), bytes.TrimSpace([]byte(cur.Body))) {
fmt.Fprintln(cmd.OutOrStdout(), "nothing changed")
return nil
}
err = putSettings(cmd, client, cur, edited, note)
if err == nil {
return nil
}
if strings.Contains(err.Error(), "changed since they were read") {
// Somebody else's change came first: opening the editor
// again on the old base would only be refused again.
return fmt.Errorf("%w; your edit is not saved", err)
}
body, problem = edited, err.Error()
}
},
}
c.Flags().StringVar(&note, "note", "", "why, kept with the version")
return c
}
func stripProblem(b []byte) []byte {
if bytes.HasPrefix(b, []byte("# NOT SAVED: ")) {
if i := bytes.IndexByte(b, '\n'); i >= 0 {
return b[i+1:]
}
}
return b
}
func runEditor(file string, in io.Reader, out, errOut io.Writer) error {
editor := os.Getenv("VISUAL")
if editor == "" {
editor = os.Getenv("EDITOR")
}
if editor == "" {
editor = "vi"
}
parts := strings.Fields(editor)
c := exec.Command(parts[0], append(parts[1:], file)...) //nolint:gosec // the operator's own editor
c.Stdin, c.Stdout, c.Stderr = in, out, errOut
if err := c.Run(); err != nil {
return fmt.Errorf("%s: %w", editor, err)
}
return nil
}
func newConfigHistoryCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
var limit int
c := &cobra.Command{
Use: "history",
Short: "Versions of the cluster's settings, newest first",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var list []adminapi.SettingsView
if err := client().get(cmd.Context(), "/api/v1/settings/history?limit="+strconv.Itoa(limit), &list); err != nil {
return err
}
if *asJSON {
return emitJSON(list)
}
w := tabTo(cmd.OutOrStdout())
fmt.Fprintln(w, "VERSION\tWHEN\tBY\tNOTE")
for _, v := range list {
fmt.Fprintf(w, "%d\t%s\t%s\t%s\n", v.Version, v.At.Local().Format(time.DateTime), orUnknown(v.By), v.Note)
}
return w.Flush()
},
}
c.Flags().IntVar(&limit, "limit", 20, "how many")
return c
}
func newConfigRollbackCmd(client func() *ctlClient) *cobra.Command {
var note string
c := &cobra.Command{
Use: "rollback VERSION",
Short: "Put an earlier version of the cluster's settings back in force, as a new version",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
n, err := strconv.ParseInt(args[0], 10, 64)
if err != nil || n < 1 {
return errors.New("VERSION is a number from 'llmgw ctl config history'")
}
var cur, old adminapi.SettingsView
if err := client().get(cmd.Context(), "/api/v1/settings", &cur); err != nil {
return err
}
if err := client().get(cmd.Context(), "/api/v1/settings?version="+args[0], &old); err != nil {
return err
}
if note == "" {
note = "back to version " + args[0]
}
return putSettings(cmd, client, cur, []byte(old.Body), note)
},
}
c.Flags().StringVar(&note, "note", "", "why, kept with the version")
return c
}
+22
View File
@@ -0,0 +1,22 @@
package cli
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestAPageInsteadOfTheAPIIsSaidPlainly(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte("<!doctype html><title>llmgw</title>"))
}))
defer srv.Close()
c := newCtlClient(strings.TrimPrefix(srv.URL, "http://"), "")
var out map[string]any
err := c.get(t.Context(), "/api/v1/settings", &out)
if err == nil || !strings.Contains(err.Error(), "older release") || strings.Contains(err.Error(), "invalid character") {
t.Errorf("%v", err)
}
}
+5 -2
View File
@@ -76,8 +76,11 @@ func newRequestCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
fmt.Fprintf(out, "tags %s\n", strings.Join(kv, ", "))
}
if v.GuardAction != "" {
fmt.Fprintf(out, "guard %s by policy %s (%s), %d ms\n", v.GuardAction, v.GuardPolicy,
or(v.GuardCategory, "-"), v.GuardMs)
what := or(v.GuardCategory, "-")
if v.GuardRule != "" {
what += ": " + v.GuardRule
}
fmt.Fprintf(out, "guard %s by policy %s (%s), %d ms\n", v.GuardAction, v.GuardPolicy, what, v.GuardMs)
} else if v.GuardMs > 0 {
fmt.Fprintf(out, "guard allowed, %d ms\n", v.GuardMs)
}
+13 -4
View File
@@ -12,9 +12,10 @@ import (
func newRestartCmd(client func() *ctlClient) *cobra.Command {
var (
models []string
all bool
inPlace bool
models []string
all bool
inPlace bool
surge, unavailable int
)
c := &cobra.Command{
Use: "restart [REPLICA...]",
@@ -31,6 +32,10 @@ func newRestartCmd(client func() *ctlClient) *cobra.Command {
"profile edited to need more than any node has — the restart is given up\n" +
"and the old replica keeps serving: llmgw ctl replicas says so, and why.\n\n" +
"--in-place stops each replica first, whatever room there is.\n\n" +
"A model's replicas are replaced one at a time. --max-surge N replaces N at\n" +
"once, each beside itself, needing room for N more; --max-unavailable N\n" +
"stops N at once first, needing no room and leaving N fewer serving while\n" +
"they load. Together, the first N are stopped first and the rest surged.\n\n" +
" llmgw ctl restart qwen2.5-7b-3f2a91c0\n" +
" llmgw ctl restart --model qwen2.5-7b\n" +
" llmgw ctl restart --all",
@@ -38,7 +43,8 @@ func newRestartCmd(client func() *ctlClient) *cobra.Command {
if len(args) == 0 && len(models) == 0 && !all {
return errors.New("name replicas, --model, or --all")
}
body, _ := json.Marshal(adminapi.RestartRequest{Replicas: args, Models: models, All: all, InPlace: inPlace})
body, _ := json.Marshal(adminapi.RestartRequest{Replicas: args, Models: models, All: all, InPlace: inPlace,
MaxSurge: surge, MaxUnavailable: unavailable})
var res adminapi.RestartResult
if err := client().post(cmd.Context(), "/api/v1/restart", body, &res); err != nil {
return err
@@ -50,6 +56,9 @@ func newRestartCmd(client func() *ctlClient) *cobra.Command {
c.Flags().StringArrayVar(&models, "model", nil, "every replica of this model; repeatable")
c.Flags().BoolVar(&all, "all", false, "every ready replica in the cluster")
c.Flags().BoolVar(&inPlace, "in-place", false, "stop each replica before starting its replacement")
c.Flags().IntVar(&surge, "max-surge", 0, "replicas of a model replaced beside themselves at once (default 1)")
c.Flags().IntVar(&unavailable, "max-unavailable", 0,
"replicas of a model stopped first at once, each out while its replacement loads")
return c
}
+30 -7
View File
@@ -152,8 +152,20 @@ func upgradeOtherControlPlanes(ctx context.Context, out io.Writer, client func()
return nil
}
// controlPlaneStep is what this machine needs to run v: the binary installed,
// the service restarted, both, or neither. The binary is this command's own;
// running is what the server says it runs, "" from one too old to say. A
// binary put in place by hand is not a server running it.
func controlPlaneStep(binary, running, v string) (install, restart bool) {
install = binary != v
return install, install || running != v
}
func upgradeControlPlane(ctx context.Context, out io.Writer, client func() *ctlClient, v, releases, svc string) error {
if version.Version == v {
var sum adminapi.Summary
_ = client().get(ctx, "/api/v1/summary", &sum)
install, restart := controlPlaneStep(version.Version, sum.Version, v)
if !restart {
fmt.Fprintf(out, "this machine already runs %s\n", v)
return nil
}
@@ -165,12 +177,20 @@ func upgradeControlPlane(ctx context.Context, out io.Writer, client func() *ctlC
if err != nil {
return err
}
fmt.Fprintf(out, "== this machine: %s -> %s\n", version.Version, v)
backup, err := release.Install(ctx, nil, releases, v, runtime.GOOS, runtime.GOARCH, self)
if err != nil {
return err
backup := "the binary installed by hand before it"
if install {
fmt.Fprintf(out, "== this machine: %s -> %s\n", version.Version, v)
if backup, err = release.Install(ctx, nil, releases, v, runtime.GOOS, runtime.GOARCH, self); err != nil {
return err
}
fmt.Fprintf(out, " installed, checksum verified; the old binary is %s\n", backup)
} else {
running := sum.Version
if running == "" {
running = "an older release"
}
fmt.Fprintf(out, "== this machine: %s is installed, and %s still runs it; restarting %s\n", v, running, svc)
}
fmt.Fprintf(out, " installed, checksum verified; the old binary is %s\n", backup)
var before []adminapi.NodeView
_ = client().get(ctx, "/api/v1/nodes", &before)
if b, err := exec.CommandContext(ctx, "systemctl", "restart", svc).CombinedOutput(); err != nil { //nolint:gosec // fixed command
@@ -270,7 +290,10 @@ func upgradeWave(ctx context.Context, out io.Writer, client func() *ctlClient, v
body, _ := json.Marshal(adminapi.UpgradeRequest{Version: v})
var failed []string
for _, n := range wave {
if err := client().post(ctx, "/api/v1/nodes/"+n.ID+"/upgrade", body, nil); err != nil {
// The control plane waits for the agent to fetch and check the
// release, up to five minutes; fifteen seconds gave up on a node
// that was upgrading.
if err := client().patient(6*time.Minute).post(ctx, "/api/v1/nodes/"+n.ID+"/upgrade", body, nil); err != nil {
failed = append(failed, fmt.Sprintf("%s: %v", n.ID, err))
}
}
+19
View File
@@ -131,3 +131,22 @@ func TestAnUpgradeKnowsWhetherItRunsOnTheLeader(t *testing.T) {
t.Error("the leader took itself for a standby")
}
}
func TestABinaryInstalledByHandIsNotAServerRunningIt(t *testing.T) {
// The binary put in place with install, llmgw-server not restarted: the
// upgrade said "already runs" and left the old server running.
for _, c := range []struct {
binary, running string
install, restart bool
}{
{"v1.0.0", "v1.0.0", true, true}, // the usual: fetch it, restart
{"v1.0.1", "v1.0.0", false, true}, // installed by hand: restart only
{"v1.0.1", "", false, true}, // a server too old to say
{"v1.0.1", "v1.0.1", false, false},
} {
install, restart := controlPlaneStep(c.binary, c.running, "v1.0.1")
if install != c.install || restart != c.restart {
t.Errorf("binary %s, running %q: install %v restart %v", c.binary, c.running, install, restart)
}
}
}
+22 -7
View File
@@ -63,9 +63,10 @@ that file when the report is not enough to go on.`,
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(struct {
Findings []doctor.Finding `json:"findings"`
Facts doctor.Facts `json:"facts"`
}{findings, facts})
Findings []doctor.Finding `json:"findings"`
Readiness doctor.Readiness `json:"readiness"`
Facts doctor.Facts `json:"facts"`
}{findings, doctor.ReadinessOf(findings), facts})
}
printDoctor(os.Stdout, facts, findings, verbose)
for _, f := range findings {
@@ -101,12 +102,26 @@ func printDoctor(out io.Writer, f doctor.Facts, findings []doctor.Finding, verbo
fmt.Fprintf(out, "%s · %s %s %s · driver %s\n\n", f.Hostname, f.Host.OSID, f.Host.OSVersion,
f.Host.Arch, orDash(f.Host.NVIDIADriver))
for _, x := range findings {
fmt.Fprintf(out, "%-4s %-6s %s\n", x.Level, x.Area, x.Message)
if x.Fix != "" && (x.Level == doctor.Fail || x.Level == doctor.Warn) {
fmt.Fprintf(out, " → %s\n", x.Fix)
// The node first; then what replicas spanning nodes would need, which
// keeps a node out only when a fabric was declared for it.
multi := false
for _, pass := range []bool{false, true} {
for _, x := range findings {
if x.MultiNode() != pass {
continue
}
if pass && !multi {
fmt.Fprintln(out, "\nMulti-node (fabrics, RDMA) — for replicas spanning nodes only:")
multi = true
}
fmt.Fprintf(out, "%-4s %-6s %s\n", x.Level, x.Area, x.Message)
if x.Fix != "" && (x.Level == doctor.Fail || x.Level == doctor.Warn) {
fmt.Fprintf(out, " → %s\n", x.Fix)
}
}
}
r := doctor.ReadinessOf(findings)
fmt.Fprintf(out, "\nThis node: %s\nMulti-node: %s\n", r.Node, r.MultiNode)
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
if len(f.GPUs) > 0 {
+166 -1
View File
@@ -4,10 +4,15 @@ import (
"context"
"fmt"
"io"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"github.com/spf13/cobra"
@@ -22,6 +27,7 @@ func newEngineCheckCmd() *cobra.Command {
nodes int
tp int
perNode int
model string
)
cmd := &cobra.Command{
@@ -31,6 +37,10 @@ func newEngineCheckCmd() *cobra.Command {
"engines rename and remove flags between versions. When that happens the\n" +
"failure arrives minutes into a model load, in an engine's own error\n" +
"message, on a node nobody was watching.\n\n" +
"For llama.cpp on a machine with NVIDIA GPUs it then loads a small GGUF\n" +
"(--model, or the smallest in the weights cache) with every layer on the GPU\n" +
"and asks for one token: a build whose CUDA backend does not load computes on\n" +
"the CPU, and that is where it is caught.\n\n" +
"This asks the engine itself. It runs the engine's --help and checks every\n" +
"flag llmgw would pass against it, which needs no GPU — a CLI prints its\n" +
"help on any machine. Run it after upgrading an engine.\n\n" +
@@ -49,6 +59,11 @@ func newEngineCheckCmd() *cobra.Command {
// rarely on PATH: a check that cannot find the engine reports
// nothing rather than reporting a problem.
cfg := engines.Config{}
// The build the agent installed, when none is named: the newest
// under /opt/llmgw/engines, which is where every node keeps it.
if bin == "" && name == "llamacpp" {
bin = installedLlama()
}
if bin != "" {
switch name {
case "llamacpp":
@@ -135,6 +150,11 @@ func newEngineCheckCmd() *cobra.Command {
var deviceErr error
if fb, ok := eng.(engines.CPUFallback); ok && help == "" {
deviceErr = checkDevices(cmd.Context(), name, fb, out)
// Seeing a GPU is not computing on it: a small model loaded
// on it and asked for a token is.
if b, ok := eng.(interface{ Binary() string }); ok && deviceErr == nil && model != "none" {
deviceErr = computeOnGPU(cmd.Context(), b.Binary(), model, out)
}
}
fmt.Fprintf(out, "%s: %d flags checked", name, checked)
@@ -160,7 +180,9 @@ func newEngineCheckCmd() *cobra.Command {
f := cmd.Flags()
f.StringVar(&help, "help-output", "", "a saved --help to check against, instead of running the engine")
f.StringVar(&bin, "bin", "", "where the engine lives, when it is not on PATH")
f.StringVar(&bin, "bin", "", "where the engine lives (default: the build the agent installed, else PATH)")
f.StringVar(&model, "model", "", "a GGUF file to load on the GPU and ask for a token (default: the smallest "+
"in this node's weights cache; none skips it)")
f.IntVar(&nodes, "nodes", 2, "shape the command for a replica of this many nodes")
f.IntVar(&tp, "tp", 8, "tensor parallel degree")
f.IntVar(&perNode, "gpus-per-node", 4, "GPUs each node contributes")
@@ -286,3 +308,146 @@ func checkDevices(ctx context.Context, name string, fb engines.CPUFallback, out
fmt.Fprintf(out, "computes on: %s\n", strings.Join(devs, "; "))
return nil
}
// llamaRoot is where the agent installs llama.cpp builds.
var llamaRoot = "/opt/llmgw/engines/llamacpp"
// installedLlama is the llama-server the agent installed here: under
// llamaRoot/<version-variant>/llama-<build>/, or one level up for older
// layouts; on a machine with NVIDIA GPUs a CUDA build first, then the newest.
func installedLlama() string {
var found []string
for _, pat := range []string{"*/llama-server", "*/*/llama-server"} {
m, _ := filepath.Glob(filepath.Join(llamaRoot, pat))
found = append(found, m...)
}
if len(found) == 0 {
return ""
}
sort.Strings(found)
if _, err := exec.LookPath("nvidia-smi"); err == nil {
for i := len(found) - 1; i >= 0; i-- {
if strings.Contains(found[i], "cuda") {
return found[i]
}
}
}
return found[len(found)-1]
}
// weightDirs are where a node keeps models it has fetched.
var weightDirs = []string{"/var/lib/llmgw-weights", "/var/lib/llmgw-models", "/srv/models"}
// smallestGGUF is the smallest GGUF file under dirs, for a check that only
// needs something to load.
func smallestGGUF(dirs []string) string {
best, size := "", int64(0)
for _, d := range dirs {
_ = filepath.WalkDir(d, func(p string, e os.DirEntry, err error) error {
if err != nil || e.IsDir() || !strings.HasSuffix(strings.ToLower(p), ".gguf") ||
strings.Contains(strings.ToLower(filepath.Base(p)), "mmproj") {
return nil
}
if info, err := e.Info(); err == nil && (best == "" || info.Size() < size) {
best, size = p, info.Size()
}
return nil
})
}
return best
}
// computeOnGPU loads a model with every layer on the GPU, asks for one
// token, and reads from llama.cpp's own output where the layers went.
func computeOnGPU(ctx context.Context, bin, model string, out io.Writer) error {
if _, err := exec.LookPath("nvidia-smi"); err != nil {
return nil
}
if model == "" {
if model = smallestGGUF(weightDirs); model == "" {
fmt.Fprintln(out, "no GGUF in this node's weights cache to compute with; --model PATH runs one")
return nil
}
}
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return err
}
port := ln.Addr().(*net.TCPAddr).Port
_ = ln.Close()
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
var logs syncBuffer
c := exec.CommandContext(ctx, bin, "-m", model, "--host", "127.0.0.1", "--port", strconv.Itoa(port), //nolint:gosec // the engine's own binary
"--n-gpu-layers", "999", "--ctx-size", "512", "--parallel", "1",
// Where the layers went is said only at this verbosity.
"-lv", "4")
c.Stdout, c.Stderr = &logs, &logs
if err := c.Start(); err != nil {
return fmt.Errorf("starting %s: %w", bin, err)
}
defer func() { _ = c.Process.Kill(); _ = c.Wait() }()
base := "http://127.0.0.1:" + strconv.Itoa(port)
start := time.Now()
for {
resp, err := http.Get(base + "/health") //nolint:gosec,noctx // loopback, bounded by the loop
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
break
}
}
if ctx.Err() != nil {
return fmt.Errorf("%s did not come up with %s: %s", bin, filepath.Base(model), tailLines(logs.String(), 5))
}
time.Sleep(500 * time.Millisecond)
}
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, base+"/completion",
strings.NewReader(`{"prompt":"Hello","n_predict":1}`))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("asking it for a token: %w", err)
}
_ = resp.Body.Close()
took := time.Since(start)
comp, said := engines.ReadLlamaCompute(strings.Split(logs.String(), "\n"))
if !said {
fmt.Fprintf(out, "computed a token with %s, but llama.cpp did not say where\n", filepath.Base(model))
return nil
}
if comp.OnTheCPU() {
return fmt.Errorf("%s computed on the CPU though every layer was asked for on the GPU (%d of %d offloaded): "+
"its CUDA backend did not load; ldd on its libggml-cuda.so names the missing library",
filepath.Base(model), comp.Offloaded, comp.Total)
}
fmt.Fprintf(out, "computed a token on %s: %s, %d of %d layers offloaded, in %s\n", comp.Backend,
strings.Join(comp.Devices, "; "), comp.Offloaded, comp.Total, took.Round(100*time.Millisecond))
return nil
}
func tailLines(s string, n int) string {
l := strings.Split(strings.TrimSpace(s), "\n")
if len(l) > n {
l = l[len(l)-n:]
}
return strings.Join(l, " | ")
}
// syncBuffer is a buffer two writers share.
type syncBuffer struct {
mu sync.Mutex
b strings.Builder
}
func (s *syncBuffer) Write(p []byte) (int, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.Write(p)
}
func (s *syncBuffer) String() string {
s.mu.Lock()
defer s.mu.Unlock()
return s.b.String()
}
+86
View File
@@ -0,0 +1,86 @@
package cli
import (
"bytes"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
)
// TestMain lets the test binary stand in for llama-server: run with
// LLMGW_FAKE_LLAMA set, it prints what llama.cpp prints and answers.
func TestMain(m *testing.M) {
if mode := os.Getenv("LLMGW_FAKE_LLAMA"); mode != "" {
fakeLlama(mode)
return
}
os.Exit(m.Run())
}
func fakeLlama(mode string) {
port := ""
for i, a := range os.Args {
if a == "--port" && i+1 < len(os.Args) {
port = os.Args[i+1]
}
}
if mode == "gpu" {
fmt.Println("llama_model_load_from_file_impl: using device CUDA0 (Quadro RTX 6000) - 23800 MiB free")
fmt.Println("load_tensors: offloaded 25/25 layers to GPU")
} else {
fmt.Println("warning: no usable GPU found, --gpu-layers option will be ignored")
fmt.Println("load_tensors: offloaded 0/25 layers to GPU")
}
http.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
http.HandleFunc("/completion", func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"content":"!"}`)) })
_ = http.ListenAndServe("127.0.0.1:"+port, nil) //nolint:gosec // a test's stand-in
}
func TestEngineCheckComputesATokenOnTheGPU(t *testing.T) {
// nvidia-smi present, so the check runs.
dir := t.TempDir()
smi := filepath.Join(dir, "nvidia-smi")
if err := os.WriteFile(smi, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
model := filepath.Join(dir, "tiny.gguf")
_ = os.WriteFile(model, []byte("GGUF"), 0o644)
self, _ := os.Executable()
t.Setenv("LLMGW_FAKE_LLAMA", "gpu")
var out bytes.Buffer
if err := computeOnGPU(t.Context(), self, model, &out); err != nil || !strings.Contains(out.String(), "on CUDA") ||
!strings.Contains(out.String(), "25 of 25") {
t.Fatalf("on the GPU: %v %s", err, out.String())
}
t.Setenv("LLMGW_FAKE_LLAMA", "cpu")
out.Reset()
if err := computeOnGPU(t.Context(), self, model, &out); err == nil || !strings.Contains(err.Error(), "computed on the CPU") {
t.Fatalf("a build that falls back to the CPU: %v %s", err, out.String())
}
}
func TestEngineCheckFindsTheBuildTheAgentInstalled(t *testing.T) {
old := llamaRoot
llamaRoot = t.TempDir()
t.Cleanup(func() { llamaRoot = old })
if got := installedLlama(); got != "" {
t.Fatalf("nothing installed, found %q", got)
}
for _, p := range []string{"b10991-cpu/llama-b10991/llama-server", "b10991-cuda-13.3/llama-b10991/llama-server"} {
full := filepath.Join(llamaRoot, p)
_ = os.MkdirAll(filepath.Dir(full), 0o755)
_ = os.WriteFile(full, []byte("x"), 0o755)
}
dir := t.TempDir()
_ = os.WriteFile(filepath.Join(dir, "nvidia-smi"), []byte("#!/bin/sh\n"), 0o755)
t.Setenv("PATH", dir)
if got := installedLlama(); !strings.Contains(got, "cuda-13.3/llama-b10991/llama-server") {
t.Errorf("with GPUs, not the CUDA build: %q", got)
}
}
+176
View File
@@ -0,0 +1,176 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/netip"
"os"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
)
// firewallStateFile is where a control plane writes what the cluster's
// settings say of the firewall, for llmgw-firewall-sync to apply as root:
// the server itself runs as llmgw and cannot change the ruleset.
const firewallStateFile = "/var/lib/llmgw/firewall.json"
// firewallState is what is written there.
type firewallState struct {
Enabled bool `json:"enabled"`
Networks []string `json:"networks,omitempty"`
}
// writeFirewallState records the settings' firewall for this machine's sync
// unit. Without the state directory — a server not run by systemd — there
// is no unit to read it, and nothing is written.
func writeFirewallState(cfg config.Server) error {
if _, err := os.Stat("/var/lib/llmgw"); err != nil {
return nil //nolint:nilerr // not a control plane set up by init
}
b, _ := json.Marshal(firewallState{Enabled: cfg.Firewall.Enabled, Networks: cfg.FirewallNetworks})
if old, err := os.ReadFile(firewallStateFile); err == nil && string(old) == string(b) {
return nil
}
tmp := firewallStateFile + ".new"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, firewallStateFile)
}
// The units that apply it: a path unit watching the file, and the oneshot it
// starts.
const (
firewallSyncPathUnit = "llmgw-firewall-sync.path"
firewallSyncUnitFile = `[Unit]
Description=llmgw firewall: apply the cluster's settings on this control plane
[Service]
Type=oneshot
ExecStart=/usr/local/bin/llmgw firewall sync
`
firewallSyncPathFile = `[Unit]
Description=llmgw firewall: follow the cluster's settings on this control plane
[Path]
PathChanged=` + firewallStateFile + `
[Install]
WantedBy=multi-user.target
`
)
func newFirewallCmd() *cobra.Command {
c := &cobra.Command{
Use: "firewall",
Short: "llmgw's firewall on this machine",
Long: "Whether llmgw keeps a firewall is the cluster's setting firewall.enabled\n" +
"(llmgw ctl config set firewall.enabled false). Nodes are told by the\n" +
"control plane; a control plane writes it to " + firewallStateFile + ",\n" +
"which " + firewallSyncPathUnit + " applies with 'llmgw firewall sync'.",
}
var install bool
sync := &cobra.Command{
Use: "sync",
Short: "Apply what the cluster's settings say of the firewall, here (root)",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if os.Geteuid() != 0 {
return errors.New("the firewall is changed as root: run it with sudo")
}
if install {
if err := installFirewallSync(cmd.Context(), firewall.Local{}); err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), firewallSyncPathUnit+" follows the cluster's settings here")
if _, err := os.Stat(firewallStateFile); err != nil {
return nil // the server writes it when it starts
}
}
b, err := os.ReadFile(firewallStateFile)
if err != nil {
return err
}
var st firewallState
if err := json.Unmarshal(b, &st); err != nil {
return fmt.Errorf("%s: %w", firewallStateFile, err)
}
ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute)
defer cancel()
msg, err := syncFirewall(ctx, firewall.Local{}, st)
if err != nil {
return err
}
fmt.Fprintln(cmd.OutOrStdout(), msg)
return nil
},
}
sync.Flags().BoolVar(&install, "install", false, "first install the units that run this whenever the settings change")
c.AddCommand(sync)
return c
}
// installFirewallSync writes the sync units on a machine and has the path
// unit follow the state file.
func installFirewallSync(ctx context.Context, r firewall.Runner) error {
body := firewallSyncUnitFile + "\x00" + firewallSyncPathFile
out, err := r.Run(ctx, `set -e
t=$(mktemp -d); trap 'rm -rf "$t"' EXIT
cat > "$t/all"
python3 - "$t" <<'PY'
import sys
d = sys.argv[1]
a, b = open(d + "/all", "rb").read().split(b"\x00")
open(d + "/service", "wb").write(a)
open(d + "/path", "wb").write(b)
PY
sudo install -m 644 "$t/service" /etc/systemd/system/llmgw-firewall-sync.service
sudo install -m 644 "$t/path" /etc/systemd/system/`+firewallSyncPathUnit+`
sudo systemctl daemon-reload
sudo systemctl enable --now `+firewallSyncPathUnit+` >/dev/null 2>&1`, []byte(body))
if err != nil {
return fmt.Errorf("installing %s: %v: %s", firewallSyncPathUnit, err, string(out))
}
return nil
}
// syncFirewall puts a firewall state in force on this machine.
func syncFirewall(ctx context.Context, r firewall.Runner, st firewallState) (string, error) {
if !st.Enabled {
had, err := firewall.Disable(ctx, r)
if err != nil {
return "", err
}
if had {
return "llmgw's firewall is off here", nil
}
return "llmgw's firewall was already off here", nil
}
loaded, err := firewall.Enable(ctx, r)
if err != nil {
return "", err
}
if !loaded {
return "no llmgw rules on this machine to put back; 'llmgw server init' writes them", nil
}
var extra []netip.Prefix
for _, n := range st.Networks {
if p, err := netip.ParsePrefix(n); err == nil {
extra = append(extra, p)
} else if a, err := netip.ParseAddr(n); err == nil {
extra = append(extra, netip.PrefixFrom(a, a.BitLen()))
}
}
if len(extra) > 0 {
if _, err := firewall.Install(ctx, r, firewall.Spec{Allow: extra}); err != nil {
return "", err
}
}
return "llmgw's firewall is on here", nil
}
+51
View File
@@ -0,0 +1,51 @@
package cli
import (
"context"
"strings"
"testing"
)
type scriptLog struct {
scripts []string
reply func(script string) string
}
func (s *scriptLog) Run(_ context.Context, script string, _ []byte) ([]byte, error) {
s.scripts = append(s.scripts, script)
if s.reply != nil {
return []byte(s.reply(script)), nil
}
return nil, nil
}
func TestAControlPlaneFollowsTheFirewallSetting(t *testing.T) {
off := &scriptLog{}
if _, err := syncFirewall(context.Background(), off, firewallState{Enabled: false}); err != nil {
t.Fatal(err)
}
if !strings.Contains(strings.Join(off.scripts, "\n"), "nft delete table inet llmgw") {
t.Errorf("off did not take the table out: %q", off.scripts)
}
none := &scriptLog{reply: func(s string) string {
if strings.Contains(s, "echo loaded") {
return "none\n"
}
return ""
}}
msg, err := syncFirewall(context.Background(), none, firewallState{Enabled: true})
if err != nil || !strings.Contains(msg, "server init") {
t.Errorf("on, with no rules here: %q %v", msg, err)
}
on := &scriptLog{reply: func(s string) string {
if strings.Contains(s, "echo loaded") {
return "loaded\n"
}
return ""
}}
if msg, err := syncFirewall(context.Background(), on, firewallState{Enabled: true}); err != nil || msg != "llmgw's firewall is on here" {
t.Errorf("on: %q %v", msg, err)
}
}
+36 -1
View File
@@ -3,18 +3,21 @@ package cli
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
)
func newGuardCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
c := &cobra.Command{
Use: "guard",
Short: "Guardrail policies: which apply where",
Long: "Policies are defined in server.yaml under guard.policies — what requests and\n" +
Long: "Policies are defined in the cluster's settings under guard.policies\n" +
"(llmgw ctl config edit; in force everywhere within seconds) — what requests and\n" +
"answers are checked for (personal data, secrets, prompt injection, patterns,\n" +
"tools) and what is done: flag, redact or block. guard.default applies to\n" +
"every request; attach others to an organization, team, project, user or\n" +
@@ -23,6 +26,35 @@ func newGuardCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
"Targets are written org:Acme, team:Acme/ml, project:Acme/ml/training (or\n" +
"with IDs), key:KEY-ID or user:USER-ID. An attachment applies at once.",
}
c.AddCommand(newGuardJudgeCmd(client, asJSON))
for _, sw := range []struct {
use, short string
on bool
}{
{"on", "Turn the guardrails on: every policy checks again", true},
{"off", "Turn the guardrails off: no request is checked, whatever the policies say", false},
} {
var note string
cmd := &cobra.Command{
Use: sw.use,
Short: sw.short,
Long: "guard.enabled in the cluster's settings: in force on every control plane\n" +
"within seconds, without a restart. The policies and where they are attached\n" +
"stay as they are, and come back as they were when turned on again. While\n" +
"off, llmgw ctl status says so.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if note == "" {
note = "guardrails " + sw.use
}
return changeSettings(cmd, client, note, func(doc *settings.Doc) error {
return doc.SetYAML([]string{"guard", "enabled"}, strconv.FormatBool(sw.on))
})
},
}
cmd.Flags().StringVar(&note, "note", "", "why, kept with the settings' version")
c.AddCommand(cmd)
}
c.AddCommand(&cobra.Command{
Use: "list",
Short: "The policies, and where each is attached",
@@ -36,6 +68,9 @@ func newGuardCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
}
out := cmd.OutOrStdout()
if !v.Enabled {
fmt.Fprintln(out, "The guardrails are OFF: no request is checked (llmgw ctl guard on).")
}
fmt.Fprintf(out, "Policies: %s\n", strings.Join(v.Policies, ", "))
def := strings.Join(v.Default, ", ")
if def == "" {
+385
View File
@@ -0,0 +1,385 @@
package cli
import (
"encoding/json"
"errors"
"fmt"
"io"
"slices"
"strings"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
)
func newGuardJudgeCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
c := &cobra.Command{
Use: "judge",
Short: "The judge models the policies ask: whether each can answer, and serving one",
Long: "A policy with a judge has a model read what patterns cannot. The judge must\n" +
"be in the catalog under the name the policy gives, as a judge (role:\n" +
"guard), with a replica ready; otherwise what the policy judges is refused\n" +
"(fail: closed, guard_unavailable) or served unjudged (fail: open).\n\n" +
"A policy may name several, asked in turn (judge.models): a judge on a GPU\n" +
"first and one on CPUs after it is the GPU's speed while it is there, and\n" +
"the CPUs' when it is not.\n\n" +
"Judges llmgw knows where to fetch, which a policy may name and the leader\n" +
"then fetches and serves by itself: " + knownJudgeNames() + ".",
}
c.AddCommand(newGuardJudgeStatusCmd(client, asJSON), newGuardJudgeEnableCmd(client),
newGuardJudgeUseCmd(client), newGuardJudgeAskCmd(client, asJSON))
return c
}
func newGuardJudgeStatusCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
return &cobra.Command{
Use: "status",
Short: "Each judge the policies ask, and what is wrong with it",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var js []adminapi.JudgeView
if err := client().get(cmd.Context(), "/api/v1/guard/judges", &js); err != nil {
return err
}
if *asJSON {
return emitJSON(js)
}
out := cmd.OutOrStdout()
if len(js) == 0 {
fmt.Fprintln(out, "no policy asks a judge")
return nil
}
w := tabTo(out)
fmt.Fprintln(w, "JUDGE\tPOLICIES\tIN CATALOG\tAS JUDGE\tREADY\tSTATE")
for _, j := range js {
state := "answering"
if j.Problem != "" {
state = j.Problem
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%d/%d\t%s\n", j.Model, strings.Join(j.Policies, ","),
yesNo(j.InCatalog), yesNo(j.Judge), j.Ready, j.Wanted, state)
}
for _, j := range js {
if len(j.Then) > 0 {
fmt.Fprintf(w, "\n%s gives no verdict: %s is asked instead", j.Model, strings.Join(j.Then, ", else "))
}
}
if err := w.Flush(); err != nil {
return err
}
strays, _ := strayJudges(cmd, client, js)
sayStrays(out, strays)
return nil
},
}
}
// strayJudges are models whose weights are a judge's, under a name no policy
// asks: a judge the policies moved on from, or one served from the mirror
// page as a model clients use, as before that page knew better.
func strayJudges(cmd *cobra.Command, client func() *ctlClient, js []adminapi.JudgeView) ([]adminapi.ModelView, error) {
var models []adminapi.ModelView
if err := client().get(cmd.Context(), "/api/v1/models", &models); err != nil {
return nil, err
}
asked := map[string]bool{}
for _, j := range js {
asked[j.Model] = true
}
var out []adminapi.ModelView
for _, m := range models {
if _, judge := guard.FormatOfWeights(m.Source); !asked[m.Name] && judge {
out = append(out, m)
}
}
return out, nil
}
// sayStrays says what each stray judge is and how to be rid of it.
func sayStrays(out io.Writer, strays []adminapi.ModelView) {
for _, m := range strays {
if m.Role == catalog.RoleGuard {
fmt.Fprintf(out, "\n%s is a judge no policy asks, still running: llmgw ctl model remove %s\n", m.Name, m.Name)
continue
}
fmt.Fprintf(out, "\n%s looks like a judge's weights but is served as a model clients use, and no "+
"policy asks it: llmgw ctl model remove %s\n", m.Name, m.Name)
}
}
func newGuardJudgeEnableCmd(client func() *ctlClient) *cobra.Command {
var (
model, repo, file, format, gpuClass string
replicas, cores int
gpu, replace, detach bool
)
c := &cobra.Command{
Use: "enable",
Short: "Serve a judge: Qwen3Guard-Gen 0.6B on CPUs, or with --gpu the 4B on a GPU",
Long: "Fetches a judge's weights into the mirror (once; what is there already, its\n" +
"checksums matching, is not fetched again), adds it to the catalog as a judge\n" +
"(role: guard) and waits for a replica to answer. Which judge: --model, or\n" +
"with --gpu " + defaultGPUJudge + ", or else the first the policies ask that is\n" +
"missing, or else " + defaultJudge + ".\n\n" +
"A judge on CPUs takes no card. One on a GPU takes one card, as a standard\n" +
"model: from nothing more important, so it waits while the cards are busy;\n" +
"put a judge on CPUs after it in the policies (llmgw ctl guard judge use) so\n" +
"there is always one to ask.\n\n" +
"Known judges: " + knownJudgeNames() + ". Another is served with --repo,\n" +
"--file and --format. A model of that name already in the catalog as a\n" +
"model clients use is refused; --replace removes it first. An offline\n" +
"cluster gets its judge from its bundle with server init.",
Example: " llmgw ctl guard judge enable\n" +
" llmgw ctl guard judge enable --gpu\n" +
" llmgw ctl guard judge use " + defaultGPUJudge + " " + defaultJudge,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
out := cmd.OutOrStdout()
var js []adminapi.JudgeView
if err := client().get(ctx, "/api/v1/guard/judges", &js); err != nil {
return err
}
name := model
if name == "" && gpu {
name = defaultGPUJudge
}
if name == "" {
for _, j := range js {
if !j.InCatalog || !j.Judge {
name = j.Model
break
}
}
}
if name == "" {
name = defaultJudge
}
k, known := knownJudgeNamed(name)
switch {
case repo != "":
f := format
if f == "" {
f, _ = guard.FormatOfWeights(repo + "/" + file)
}
if f == "" || !guard.KnownFormat(f) {
return fmt.Errorf("say how its answers are read: --format %s", strings.Join(guard.Formats, "|"))
}
k = knownJudge{Name: name, Repo: repo, File: file, Format: f, GPU: gpu}
case !known:
return fmt.Errorf("%s is not a judge llmgw knows where to fetch (%s): give --repo, --file and --format", name, knownJudgeNames())
}
if gpu {
k.GPU = true
}
var models []adminapi.ModelView
if err := client().get(ctx, "/api/v1/models", &models); err != nil {
return err
}
if i := slices.IndexFunc(models, func(m adminapi.ModelView) bool { return m.Name == name }); i >= 0 {
if models[i].Role == catalog.RoleGuard {
fmt.Fprintf(out, "%s is in the catalog as a judge already; llmgw ctl guard judge status says how it is\n", name)
return nil
}
if !replace {
return fmt.Errorf("%s is in the catalog as a model clients use, not as a judge: "+
"--replace removes it and serves it again as a judge", name)
}
var res map[string]string
if err := client().delete(ctx, "/api/v1/models/"+name, &res); err != nil {
return fmt.Errorf("removing %s: %w", name, err)
}
fmt.Fprintf(out, "%s removed\n", name)
}
opts := judgeServeOptions{Replicas: replicas, Cores: cores, GPUClass: gpuClass}
if k.GPU && opts.GPUClass == "" {
var s adminapi.Summary
if err := client().get(ctx, "/api/v1/summary", &s); err != nil {
return err
}
if opts.GPUClass = roomiestGPUClass(s.GPUs); opts.GPUClass == "" {
return fmt.Errorf("this cluster has no GPU for %s: serve %s on CPUs instead", name, defaultJudge)
}
}
fmt.Fprintf(out, "serving %s\n", k)
body, _ := json.Marshal(k.serveRequest(opts))
var job modelserve.Job
if err := client().patient(30*time.Second).post(ctx, "/api/v1/models/serve", body, &job); err != nil {
return err
}
if detach {
return detached(cmd, job)
}
if err := followJob(cmd, client, job, "is the judge"); err != nil {
return err
}
if !slices.ContainsFunc(js, func(j adminapi.JudgeView) bool { return j.Model == name }) {
fmt.Fprintf(out, "\nno policy asks %s yet: llmgw ctl guard judge use %s", name, name)
if k.GPU {
fmt.Fprintf(out, " %s", defaultJudge)
}
fmt.Fprintln(out)
}
if strays, _ := strayJudges(cmd, client, js); len(strays) > 0 {
sayStrays(out, slices.DeleteFunc(strays, func(m adminapi.ModelView) bool { return m.Name == name }))
}
return nil
},
}
f := c.Flags()
f.StringVar(&model, "model", "", "the judge to serve: "+knownJudgeNames()+", or a name for --repo's")
f.BoolVar(&gpu, "gpu", false, "serve it on a GPU ("+defaultGPUJudge+" when no --model)")
f.StringVar(&gpuClass, "gpu-class", "", "the GPUs it runs on (default: the class with the most free cards)")
f.StringVar(&repo, "repo", "", "the registry repository of a judge llmgw does not know")
f.StringVar(&file, "file", "", "the GGUF file in --repo")
f.StringVar(&format, "format", "", "how --repo's judge answers: "+strings.Join(guard.Formats, ", "))
f.IntVar(&replicas, "replicas", 0, "replicas (default: 2 on CPUs, as every judged request waits on one; 1 on a GPU)")
f.IntVar(&cores, "cores", 4, "CPU cores a replica on CPUs computes on")
f.BoolVar(&replace, "replace", false, "remove a model of that name that is not a judge first")
f.BoolVar(&detach, "detach", false, "start it and return; llmgw ctl jobs follows it")
return c
}
// roomiestGPUClass is the class with the most free cards, else the most
// cards; empty when the cluster has none.
func roomiestGPUClass(classes []adminapi.ClassUse) string {
best := -1
for i, c := range classes {
if c.Total == 0 {
continue
}
if best < 0 || c.Free() > classes[best].Free() || (c.Free() == classes[best].Free() && c.Total > classes[best].Total) {
best = i
}
}
if best < 0 {
return ""
}
return classes[best].Class
}
func newGuardJudgeUseCmd(client func() *ctlClient) *cobra.Command {
var policies []string
var note string
c := &cobra.Command{
Use: "use JUDGE...",
Short: "Set the judges the policies ask, in the order they are asked",
Long: "Sets judge.models of every policy that has a judge, or of those given with\n" +
"--policy: the first judge that answers gives the verdict, and the next is\n" +
"asked when one has no replica serving, fails, or does not answer within\n" +
"the policy's latency_budget. A judge on a GPU first and one on CPUs after\n" +
"it is the GPU's speed while it is there, and the CPUs' when it is not.\n\n" +
"A judge llmgw knows (" + knownJudgeNames() + ")\n" +
"that is not in the catalog is fetched and served by the leader; another\n" +
"is served first with llmgw ctl guard judge enable --repo. In force on\n" +
"every control plane within seconds.",
Example: " llmgw ctl guard judge use " + defaultJudge + "\n" +
" llmgw ctl guard judge use " + defaultGPUJudge + " " + defaultJudge + "\n" +
" llmgw ctl guard judge use " + defaultGPUJudge + " " + defaultJudge + " --policy strict",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
for i, a := range args {
if slices.Contains(args[:i], a) {
return fmt.Errorf("%s is named twice", a)
}
}
if note == "" {
note = "judges: " + strings.Join(args, ", else ")
}
list := "[" + strings.Join(args, ", ") + "]"
return changeSettings(cmd, client, note, func(doc *settings.Doc) error {
names := policies
if len(names) == 0 {
for _, p := range doc.MapKeys([]string{"guard", "policies"}) {
if doc.Node([]string{"guard", "policies", p, "judge"}) != nil {
names = append(names, p)
}
}
if len(names) == 0 {
return errors.New("no policy has a judge: name one with --policy, and it is given one " +
"that blocks what the judge finds unsafe in requests (judge.input: block)")
}
}
for _, p := range names {
base := []string{"guard", "policies", p}
if doc.Node(base) == nil {
return fmt.Errorf("there is no policy %s (llmgw ctl guard list)", p)
}
judge := append(slices.Clone(base), "judge")
if doc.Node(judge) == nil {
if err := doc.SetYAML(append(slices.Clone(judge), "input"), "block"); err != nil {
return err
}
}
doc.Unset(append(slices.Clone(judge), "model"))
if err := doc.SetYAML(append(slices.Clone(judge), "models"), list); err != nil {
return err
}
cats := append(slices.Clone(judge), "categories")
var have []string
if n := doc.Node(cats); n != nil {
for _, c := range n.Content {
have = append(have, c.Value)
}
}
if next := categoriesFor(args, have); next != nil {
if err := doc.SetYAML(cats, "["+strings.Join(next, ", ")+"]"); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s: categories [%s] are not what %s name; it counts [%s] instead\n",
p, strings.Join(have, ", "), strings.Join(args, " or "), strings.Join(next, ", "))
}
}
return nil
})
},
}
c.Flags().StringSliceVar(&policies, "policy", nil, "the policies to change (default: every one with a judge)")
c.Flags().StringVar(&note, "note", "", "why, kept with the settings' version")
return c
}
func newGuardJudgeAskCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
var model, answer string
c := &cobra.Command{
Use: "ask TEXT",
Short: "Have a judge read a text and say what it answered",
Long: "For finding out why a request was refused: the judge reads TEXT as a user's\n" +
"message (with --answer, ANSWER as the reply to it), past the cache, and its\n" +
"answer is shown as it gave it and as the gateway reads it — \"unsafe\" and\n" +
"the hazards it named, S6 Specialized Advice and the like. Nothing is kept:\n" +
"not in the audit, the usage or the logs.",
Example: " llmgw ctl guard judge ask 'How do I sort a list in Python?'\n" +
" llmgw ctl guard judge ask 'Write a poem' --answer 'Roses are red'",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
body, _ := json.Marshal(adminapi.JudgeAskRequest{Model: model, Text: args[0], Answer: answer})
var v adminapi.JudgeAskView
if err := client().patient(45*time.Second).post(cmd.Context(), "/api/v1/guard/judges/ask", body, &v); err != nil {
return err
}
if *asJSON {
return emitJSON(v)
}
out := cmd.OutOrStdout()
fmt.Fprintf(out, "%s: %s", v.Model, v.Verdict)
if len(v.Categories) > 0 {
fmt.Fprintf(out, " (%s)", strings.Join(v.Categories, ", "))
}
fmt.Fprintf(out, ", %d ms\nit answered: %q\n", v.TookMs, v.Raw)
return nil
},
}
c.Flags().StringVar(&model, "model", "", "the judge (default: the one the policies ask)")
c.Flags().StringVar(&answer, "answer", "", "an assistant's reply to TEXT, judged as output is")
return c
}
+116
View File
@@ -0,0 +1,116 @@
package cli
import (
"context"
"log/slog"
"slices"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
)
// judgeKeeper fetches and serves, on the leader, each judge llmgw knows that
// a policy names and the catalog does not have: naming qwen3guard-4b in a
// policy is all it takes to have one, and a judge removed by mistake comes
// back. A judge it does not know is left to llmgw ctl guard judge enable
// --repo, said in guard judge status.
type judgeKeeper struct {
library func() guard.Config
models func() []catalog.Model
serve interface {
Start(modelserve.Request) (modelserve.Job, error)
Jobs() []modelserve.Job
}
leader func() bool
gpus func() []adminapi.ClassUse
log *slog.Logger
now func() time.Time
// tried is when each judge was last asked for, so one that cannot be
// fetched is not asked for again on every look.
tried map[string]time.Time
}
// judgeRetry is how long a judge whose serving failed waits before it is
// asked for again.
const judgeRetry = 10 * time.Minute
func (k *judgeKeeper) run(ctx context.Context, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
k.check()
select {
case <-ctx.Done():
return
case <-t.C:
}
}
}
func (k *judgeKeeper) check() {
if k.leader != nil && !k.leader() {
return
}
lib := k.library()
if !lib.On() {
return
}
if k.tried == nil {
k.tried = map[string]time.Time{}
}
now := time.Now
if k.now != nil {
now = k.now
}
have := map[string]bool{}
for _, m := range k.models() {
have[m.Name] = true
}
busy := map[string]bool{}
for _, j := range k.serve.Jobs() {
if j.State == modelserve.Running {
busy[j.Model] = true
busy[j.Request.Name] = true
}
}
var wanted []string
for _, p := range lib.Policies {
for _, j := range p.Judge.Judges() {
if !slices.Contains(wanted, j) {
wanted = append(wanted, j)
}
}
}
slices.Sort(wanted)
for _, name := range wanted {
kj, known := knownJudgeNamed(name)
if have[name] || !known || busy[name] {
continue
}
if at, ok := k.tried[name]; ok && now().Sub(at) < judgeRetry {
continue
}
k.tried[name] = now()
var opts judgeServeOptions
if kj.GPU {
if opts.GPUClass = roomiestGPUClass(k.gpus()); opts.GPUClass == "" {
k.log.Warn("a policy names a judge that runs on a GPU, and this cluster has none",
"judge", name, "instead", defaultJudge)
continue
}
}
req := kj.serveRequest(opts)
req.By = "llmgw (a guard policy names it)"
job, err := k.serve.Start(req)
if err != nil {
k.log.Error("could not start serving a judge a policy names", "judge", name, "err", err)
continue
}
k.log.Info("serving a judge a policy names, which the catalog did not have",
"judge", name, "repo", kj.Repo, "file", kj.File, "job", job.ID)
}
}
+126
View File
@@ -0,0 +1,126 @@
package cli
import (
"errors"
"log/slog"
"slices"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
)
type fakeServe struct {
started []modelserve.Request
running []modelserve.Job
err error
}
func (f *fakeServe) Start(r modelserve.Request) (modelserve.Job, error) {
if f.err != nil {
return modelserve.Job{}, f.err
}
f.started = append(f.started, r)
return modelserve.Job{ID: "j", Model: r.Name, Request: r, State: modelserve.Running}, nil
}
func (f *fakeServe) Jobs() []modelserve.Job { return f.running }
func TestAJudgeAPolicyNamesIsFetchedWhenTheCatalogLacksIt(t *testing.T) {
lib := guard.Config{Policies: map[string]guard.Policy{
"baseline": {Judge: &guard.JudgePolicy{Models: []string{"qwen3guard-4b", "qwen3guard-0.6b"}, Input: "block"}},
"custom": {Judge: &guard.JudgePolicy{Model: "our-own-judge", Input: "block"}},
}}
inCatalog := []catalog.Model{{Name: "qwen3guard-0.6b"}}
now := time.Date(2026, 10, 8, 12, 0, 0, 0, time.UTC)
fs := &fakeServe{}
leader := true
k := &judgeKeeper{
library: func() guard.Config { return lib },
models: func() []catalog.Model { return inCatalog },
serve: fs,
leader: func() bool { return leader },
gpus: func() []adminapi.ClassUse { return []adminapi.ClassUse{{Class: "6000-24g", Total: 1, Used: 1}} },
log: slog.New(slog.DiscardHandler),
now: func() time.Time { return now },
}
k.check()
// The GPU judge alone: the CPU one is there, and one llmgw does not know
// is not guessed at.
if len(fs.started) != 1 || fs.started[0].Name != "qwen3guard-4b" || fs.started[0].GPUClass != "6000-24g" ||
fs.started[0].Class != catalog.ClassStandard || fs.started[0].GuardFormat != "qwen3guard" || fs.started[0].CPU {
t.Fatalf("started %+v", fs.started)
}
// Being fetched: not asked for again.
fs.running = []modelserve.Job{{Model: "qwen3guard-4b", State: modelserve.Running}}
k.check()
if len(fs.started) != 1 {
t.Errorf("asked for again while it is being fetched: %d", len(fs.started))
}
// Failed: asked for again only after a while.
fs.running = nil
now = now.Add(time.Minute)
k.check()
if len(fs.started) != 1 {
t.Error("asked for again at once after it failed")
}
now = now.Add(judgeRetry)
k.check()
if len(fs.started) != 2 {
t.Error("never asked for again")
}
// Only the leader fetches, and nothing while the guardrails are off.
leader = false
now = now.Add(judgeRetry)
k.check()
leader = true
off := false
lib.Enabled = &off
k.check()
if len(fs.started) != 2 {
t.Errorf("a standby or the guardrails off fetched: %d", len(fs.started))
}
// A CPU judge on a cluster without GPUs is served on CPUs, two of it.
lib = guard.Config{Policies: map[string]guard.Policy{"b": {Judge: &guard.JudgePolicy{Model: "qwen3guard-0.6b", Input: "block"}}}}
inCatalog = nil
k.gpus = func() []adminapi.ClassUse { return nil }
k.check()
if last := fs.started[len(fs.started)-1]; last.Name != "qwen3guard-0.6b" || !last.CPU || last.Replicas != 2 {
t.Errorf("CPU judge: %+v", last)
}
fs.err = errors.New("the mirror is down")
lib = guard.Config{Policies: map[string]guard.Policy{"b": {Judge: &guard.JudgePolicy{Model: "qwen3guard-8b", Input: "block"}}}}
k.check() // no GPU, so not started, and no panic on the error path either
}
// A policy moved from Llama Guard's codes to Qwen3Guard would count nothing:
// its categories become the judge's own.
func TestCategoriesFollowTheJudges(t *testing.T) {
lg := []string{"S1", "S2", "S10"}
if got := categoriesFor([]string{"qwen3guard-0.6b"}, lg); !slices.Equal(got, baselineCategories["qwen3guard"]) {
t.Errorf("Llama Guard's codes for Qwen3Guard: %v", got)
}
if got := categoriesFor([]string{"qwen3guard-4b", "qwen3guard-0.6b"}, []string{"Violent", "Jailbreak"}); got != nil {
t.Errorf("its own names were replaced: %v", got)
}
if got := categoriesFor([]string{"llama-guard-3-1b"}, lg); got != nil {
t.Errorf("Llama Guard's own codes were replaced: %v", got)
}
// Both kinds asked in turn: each must have something to count.
got := categoriesFor([]string{"qwen3guard-4b", "llama-guard-3-1b"}, []string{"Violent"})
if !slices.Contains(got, "Violent") || !slices.Contains(got, "S1") {
t.Errorf("mixed: %v", got)
}
if got := categoriesFor([]string{"our-own-judge"}, lg); got != nil {
t.Errorf("a judge llmgw does not know: %v", got)
}
if got := categoriesFor([]string{"qwen3guard-0.6b"}, nil); got != nil {
t.Errorf("no categories (every one counts) became %v", got)
}
}
+158
View File
@@ -0,0 +1,158 @@
package cli
import (
"fmt"
"slices"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
)
// knownJudge is a judge model llmgw knows where to fetch and how to serve:
// what a policy may name without anyone saying where its weights are.
type knownJudge struct {
Name, Repo, File, Format string
// GPU is a judge too slow on CPUs to wait for: served on one card.
GPU bool
About string
}
// knownJudges, the default first. Measured on the reference cluster against
// ordinary coding requests ("continue", "kill the process on port 8080") and
// harmful ones: Llama Guard 3 1B scored both alike, about one in two
// ordinary ones refused; Qwen3Guard-Gen 0.6B kept them apart, 0.15 against
// 1.00 at the worst, at about two seconds a check on two CPU threads.
var knownJudges = []knownJudge{
{Name: "qwen3guard-0.6b", Repo: "mradermacher/Qwen3Guard-Gen-0.6B-GGUF", File: "Qwen3Guard-Gen-0.6B.Q8_0.gguf",
Format: "qwen3guard", About: "Qwen3Guard-Gen 0.6B on CPUs, 0.8 GB: the default"},
{Name: "qwen3guard-4b", Repo: "mradermacher/Qwen3Guard-Gen-4B-GGUF", File: "Qwen3Guard-Gen-4B.Q4_K_M.gguf",
Format: "qwen3guard", GPU: true, About: "Qwen3Guard-Gen 4B on a GPU, 2.5 GB"},
{Name: "qwen3guard-8b", Repo: "mradermacher/Qwen3Guard-Gen-8B-GGUF", File: "Qwen3Guard-Gen-8B.Q4_K_M.gguf",
Format: "qwen3guard", GPU: true, About: "Qwen3Guard-Gen 8B on a GPU, 4.7 GB"},
{Name: "llama-guard-3-1b", Repo: "QuantFactory/Llama-Guard-3-1B-GGUF", File: "Llama-Guard-3-1B.Q4_K_M.gguf",
Format: "llamaguard3", About: "Llama Guard 3 1B on CPUs, 0.9 GB: refuses ordinary coding requests"},
{Name: "llama-guard-3-8b", Repo: "QuantFactory/Llama-Guard-3-8B-GGUF", File: "Llama-Guard-3-8B.Q4_K_M.gguf",
Format: "llamaguard3", GPU: true, About: "Llama Guard 3 8B on a GPU, 4.6 GB"},
}
const (
defaultJudge = "qwen3guard-0.6b"
defaultGPUJudge = "qwen3guard-4b"
)
// baselineCategories are what the baseline policy counts of each format's
// verdicts: not PII, which is the patterns', nor what refuses ordinary work
// — copyright, unethical acts, politically sensitive topics; Llama Guard's
// defamation, specialized advice, intellectual property and elections.
var baselineCategories = map[string][]string{
"qwen3guard": {"Violent", "Non-violent Illegal Acts", "Sexual Content or Sexual Acts", "Suicide & Self-Harm", "Jailbreak"},
"llamaguard3": {"S1", "S2", "S3", "S4", "S7", "S9", "S10", "S11", "S12"},
}
// reported is whether a judge of that format ever names category c: its own
// names, or for Llama Guard its codes.
func reported(format, c string) bool {
if slices.ContainsFunc(guard.DefaultCategories(format), func(n string) bool { return strings.EqualFold(n, c) }) {
return true
}
var n int
_, err := fmt.Sscanf(c, "S%d", &n)
return format == "llamaguard3" && err == nil && n >= 1 && n <= 14
}
// categoriesFor is what a policy asking these judges should count, when the
// categories it has are ones none of them names: a policy moved from Llama
// Guard's S1 to Qwen3Guard, which says Violent, would otherwise count
// nothing. Nil keeps what it has; so does a judge llmgw does not know.
func categoriesFor(judges, have []string) []string {
var formats []string
for _, j := range judges {
k, ok := knownJudgeNamed(j)
if !ok {
return nil
}
if !slices.Contains(formats, k.Format) {
formats = append(formats, k.Format)
}
}
if len(have) == 0 {
return nil
}
stray := false
for _, f := range formats {
for _, c := range have {
if !reported(f, c) && !slices.ContainsFunc(formats, func(o string) bool { return reported(o, c) }) {
stray = true
}
}
// Every judge must have something it counts.
if !slices.ContainsFunc(have, func(c string) bool { return reported(f, c) }) {
stray = true
}
}
if !stray {
return nil
}
var out []string
for _, f := range formats {
out = append(out, baselineCategories[f]...)
}
return out
}
func knownJudgeNamed(name string) (knownJudge, bool) {
i := slices.IndexFunc(knownJudges, func(k knownJudge) bool { return k.Name == name })
if i < 0 {
return knownJudge{}, false
}
return knownJudges[i], true
}
func knownJudgeNames() string {
var n []string
for _, k := range knownJudges {
n = append(n, k.Name)
}
return strings.Join(n, ", ")
}
// judgeServeOptions are how a judge is served beyond which weights.
type judgeServeOptions struct {
Replicas, Cores int
// GPUClass is the cards a GPU judge runs on; empty lets the plan choose.
GPUClass string
}
// serveRequest is the serve job for a judge. A judge on CPUs is critical,
// as every judged request waits on it; one on a GPU is standard, so it takes
// a card only from what may give one up, and the judge on CPUs after it in
// the policies stands in while it has none.
func (k knownJudge) serveRequest(o judgeServeOptions) modelserve.Request {
req := modelserve.Request{
Repo: k.Repo, File: k.File, Name: k.Name, GuardFormat: k.Format,
Description: "The guardrails' judge: reads prompts and answers for unsafe content. Not offered to clients.",
Replicas: o.Replicas, CtxPool: 8192, ContextLength: 4096,
}
if k.GPU {
req.GPUClass, req.GPUsPerNode, req.Class = o.GPUClass, 1, catalog.ClassStandard
if req.Replicas == 0 {
req.Replicas = 1
}
return req
}
req.CPU, req.CPUCores = true, o.Cores
if req.Replicas == 0 {
req.Replicas = 2
}
return req
}
func (k knownJudge) String() string {
where := "CPUs"
if k.GPU {
where = "a GPU"
}
return fmt.Sprintf("%s (%s, %s/%s, on %s)", k.Name, k.Format, k.Repo, k.File, where)
}
+242
View File
@@ -0,0 +1,242 @@
package cli
import (
"encoding/json"
"fmt"
"net/url"
"strings"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
func newKBCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
c := &cobra.Command{
Use: "kb",
Short: "Knowledge bases: collections of documents requests are answered from",
Long: "A collection belongs to an organization, team, project or person, and is\n" +
"embedded by one model of the catalog. People put documents into it from\n" +
"their own machines with llmgw-kb (its own download, for Windows, macOS and\n" +
"Linux), which sends text and vectors, never the files.\n\n" +
"Who may read or add to a collection: its owner and everything below it,\n" +
"and whoever it is granted to (grant), never outside its organization.\n" +
"Targets are written org:Acme, team:Acme/hr, project:Acme/hr/docs (or with\n" +
"IDs), key:KEY-ID or user:USER-ID. A collection is named by its ID, its name\n" +
"when that is unique, or ORG/NAME.\n\n" +
"Needs the PostgreSQL store with pgvector; `llmgw ctl kb list` says why not.",
}
c.AddCommand(&cobra.Command{
Use: "list",
Short: "Every collection: owner, model, documents and grants",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var v adminapi.KBView
if err := client().get(cmd.Context(), "/api/v1/kb", &v); err != nil {
return err
}
if *asJSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
}
out := cmd.OutOrStdout()
if !v.Available {
fmt.Fprintln(out, "Knowledge bases are off: "+v.Reason)
return nil
}
if len(v.Collections) == 0 {
fmt.Fprintln(out, "No collections yet (llmgw ctl kb create).")
return nil
}
w := tab()
fmt.Fprintln(w, "COLLECTION\tOWNER\tMODEL\tDOCUMENTS\tCHUNKS\tGRANTED TO")
for _, c := range v.Collections {
var grants []string
for _, g := range c.Grants {
grants = append(grants, or(g.Name, g.Target)+" ("+g.Perm+")")
}
fmt.Fprintf(w, "%s/%s\t%s\t%s (%d)\t%d\t%d\t%s\n", or(c.OrgName, c.Org), c.Name, or(c.OwnerName, c.Owner),
c.EmbedModel, c.Dims, c.Documents, c.Chunks, strings.Join(grants, ", "))
}
if err := w.Flush(); err != nil {
return err
}
if len(v.Defaults) > 0 {
fmt.Fprintln(out, "\nAnswered from by default, when a request names no collection:")
w = tab()
for _, d := range v.Defaults {
fmt.Fprintf(w, " %s\t%s\n", or(d.Name, d.Target), strings.Join(d.Collections, ", "))
}
return w.Flush()
}
return nil
},
})
var org string
var dims int
create := &cobra.Command{
Use: "create OWNER NAME MODEL",
Short: "Make a collection owned by an organization, team, project or person",
Long: "MODEL is a catalog model with capabilities.embeddings. Its dimension is asked\n" +
"of it, so it has to be able to serve now; --dims gives it instead.",
Example: " llmgw ctl kb create team:Acme/hr policies bge-m3\n" +
" llmgw ctl kb create user:USER-ID notes bge-m3 --org Acme",
Args: cobra.ExactArgs(3),
RunE: func(cmd *cobra.Command, args []string) error {
body, _ := json.Marshal(adminapi.KBCreateRequest{Owner: args[0], Name: args[1], Model: args[2], Org: org, Dims: dims})
var v adminapi.KBCollectionView
if err := client().do(cmd.Context(), "POST", "/api/v1/kb/collections", json.RawMessage(body), &v); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "collection %s (%s), embedded by %s in %d dimensions\n", v.Name, v.ID, v.EmbedModel, v.Dims)
fmt.Fprintf(cmd.OutOrStdout(), "people with access fill it with: llmgw-kb sync %s FOLDER\n", v.Name)
return nil
},
}
create.Flags().StringVar(&org, "org", "", "the organization of a person's collection")
create.Flags().IntVar(&dims, "dims", 0, "the model's embedding dimension, instead of asking it")
c.AddCommand(create)
c.AddCommand(&cobra.Command{
Use: "delete COLLECTION",
Short: "Remove a collection, its documents and its grants",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if err := client().do(cmd.Context(), "DELETE", "/api/v1/kb/collections/"+url.PathEscape(args[0]), nil, nil); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "collection %s removed\n", args[0])
return nil
},
})
for _, verb := range []struct {
use, short, method string
perm bool
}{
{"grant", "Let a scope and everything below it read a collection, or with --write add to it", "POST", true},
{"revoke", "Take a grant away", "DELETE", false},
} {
var write bool
cmd := &cobra.Command{
Use: verb.use + " COLLECTION TARGET",
Short: verb.short,
Example: " llmgw ctl kb " + verb.use + " Acme/policies project:Acme/sales/crm",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
req := adminapi.KBGrantRequest{Collection: args[0], Target: args[1], Perm: "read"}
if write {
req.Perm = "write"
}
body, _ := json.Marshal(req)
if err := client().do(cmd.Context(), verb.method, "/api/v1/kb/grants", json.RawMessage(body), nil); err != nil {
return err
}
if verb.perm {
fmt.Fprintf(cmd.OutOrStdout(), "%s may %s %s\n", args[1], req.Perm, args[0])
} else {
fmt.Fprintf(cmd.OutOrStdout(), "%s no longer granted %s\n", args[1], args[0])
}
return nil
},
}
if verb.perm {
cmd.Flags().BoolVar(&write, "write", false, "add and remove documents too")
}
c.AddCommand(cmd)
}
c.AddCommand(&cobra.Command{
Use: "docs COLLECTION",
Short: "A collection's documents",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var docs []adminapi.KBDocumentView
if err := client().get(cmd.Context(), "/api/v1/kb/collections/"+url.PathEscape(args[0])+"/documents", &docs); err != nil {
return err
}
if *asJSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(docs)
}
w := tab()
fmt.Fprintln(w, "DOCUMENT\tCHUNKS\tSIZE\tADDED\tBY")
for _, d := range docs {
fmt.Fprintf(w, "%s\t%d\t%s\t%s\t%s\n", d.Name, d.Chunks, humanBytes(d.Bytes), d.AddedAt.Local().Format("2006-01-02 15:04"), d.AddedBy)
}
return w.Flush()
},
})
c.AddCommand(&cobra.Command{
Use: "rm COLLECTION DOCUMENT",
Short: "Remove one document from a collection",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
path := "/api/v1/kb/collections/" + url.PathEscape(args[0]) + "/documents?name=" + url.QueryEscape(args[1])
if err := client().do(cmd.Context(), "DELETE", path, nil, nil); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s removed from %s\n", args[1], args[0])
return nil
},
})
var clear bool
def := &cobra.Command{
Use: "default TARGET [COLLECTION...]",
Short: "Answer a part of the tree's chat requests from collections, when they name none",
Long: "Requests from TARGET and everything below it are then answered from these\n" +
"collections without naming them (X-Llmgw-Knowledge). The narrowest default\n" +
"wins: a key's over its project's, a project's over its team's. A request's own\n" +
"header overrides it, and X-Llmgw-Knowledge: none turns it off. A collection a\n" +
"caller may not read is left out for them, and a search that fails answers\n" +
"without passages rather than refusing. In force within 30 seconds.",
Example: " llmgw ctl kb default team:Acme/hr Acme/policies Acme/forms\n" +
" llmgw ctl kb default team:Acme/hr --clear",
Args: cobra.MinimumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if clear != (len(args) == 1) {
return fmt.Errorf("name collections, or --clear")
}
body, _ := json.Marshal(adminapi.KBDefaultRequest{Target: args[0], Collections: args[1:]})
if err := client().do(cmd.Context(), "POST", "/api/v1/kb/defaults", json.RawMessage(body), nil); err != nil {
return err
}
if clear {
fmt.Fprintf(cmd.OutOrStdout(), "%s answers from no collection by default\n", args[0])
} else {
fmt.Fprintf(cmd.OutOrStdout(), "%s answers from %s by default\n", args[0], strings.Join(args[1:], ", "))
}
return nil
},
}
def.Flags().BoolVar(&clear, "clear", false, "no default any more")
c.AddCommand(def)
var k int
search := &cobra.Command{
Use: "search COLLECTION QUERY...",
Short: "Try a search of a collection, as an administrator (no tenancy applies)",
Args: cobra.MinimumNArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
body, _ := json.Marshal(adminapi.KBSearchRequest{Collection: args[0], Query: strings.Join(args[1:], " "), K: k})
var hits []adminapi.KBHitView
if err := client().do(cmd.Context(), "POST", "/api/v1/kb/search", json.RawMessage(body), &hits); err != nil {
return err
}
if *asJSON {
return json.NewEncoder(cmd.OutOrStdout()).Encode(hits)
}
out := cmd.OutOrStdout()
if len(hits) == 0 {
fmt.Fprintln(out, "Nothing found.")
}
for i, h := range hits {
fmt.Fprintf(out, "%d. %s #%d\n%s\n\n", i+1, h.Document, h.Ord, indent(strings.TrimSpace(h.Text), " "))
}
return nil
},
}
search.Flags().IntVarP(&k, "k", "k", 5, "how many chunks")
c.AddCommand(search)
return c
}
+1 -4
View File
@@ -89,10 +89,7 @@ func mirrorInit(ctx context.Context, out io.Writer, dir, base string, controlPla
if err := in.chown(dir, true); err != nil {
return err
}
addr := ""
if ips := localAddrs(); len(ips) > 0 {
addr = ips[0].String()
}
addr := primaryAddr()
if base == "" {
if addr == "" {
return errors.New("this machine has no address to give nodes; give one with --base")
+55 -4
View File
@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"io"
"net"
"net/netip"
"os"
"slices"
"strings"
@@ -16,7 +18,9 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
)
@@ -164,23 +168,36 @@ func (na *nodeAdd) prepare() error {
// are not known until the node says what it has, and are not applied
// here; doctor's verdict on vLLM is a warning either way.
if o.VLLMVersion == "" {
if cfg, err := config.Load(na.serverCfg); err == nil {
if cfg, err := loadEffectiveConfig(na.serverCfg); err == nil {
o.VLLMVersion = cfg.WantedEngines()["vllm"]
if o.VLLMVersion == "" {
o.VLLMVersion = "none"
}
}
}
// The node's ports — its engines', its metrics — for the cluster only.
if cfg, err := config.Load(na.serverCfg); err == nil && !o.NoFirewall {
// The node's ports — its engines', its metrics — for the cluster only,
// unless the cluster's settings turned llmgw's firewall off.
if cfg, err := loadEffectiveConfig(na.serverCfg); err == nil && !cfg.Firewall.Enabled {
o.NoFirewall = true
} else if err == nil && !o.NoFirewall {
from, to := cfg.EnginePorts()
o.FirewallPorts = []firewall.Port{firewall.Engines(from, to), firewall.NodeExporter}
o.FirewallAllow, _ = cfg.FirewallAllow()
}
// Where the mirror serves model files, for the node to be asked to reach.
if cfg, err := loadEffectiveConfig(na.serverCfg); err == nil && cfg.Mirror.API != "" {
if mc, err := mirror.NewClient(cfg.Mirror.API, cfg.Mirror.TLS, 5*time.Second); err == nil {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
if list, err := mc.List(ctx); err == nil {
o.MirrorBase = list.Base
}
cancel()
}
}
// Where the control plane upgrades nodes from, so a node joins on the
// same release from the same place: an offline cluster's mirror.
if o.Releases == "" {
if cfg, err := config.Load(na.serverCfg); err == nil {
if cfg, err := loadEffectiveConfig(na.serverCfg); err == nil {
o.Releases = cfg.ReleasesBase()
}
if o.Releases == "" {
@@ -266,6 +283,9 @@ func (na *nodeAdd) run(ctx context.Context, addrs []string, out io.Writer) error
if len(addrs) > 1 {
title += fmt.Sprintf(" (%d of %d)", i+1, len(addrs))
}
if !o.NoFirewall {
na.letIn(ctx, o.Addr, out)
}
var node string
var err error
if tui {
@@ -297,6 +317,37 @@ func (na *nodeAdd) run(ctx context.Context, addrs []string, out io.Writer) error
return nil
}
// letIn opens this control plane's firewall to the node, when it has one
// and the node is on a network it does not already let in: otherwise a node
// on another subnet is set up and then never heard from. Said, not fatal.
func (na *nodeAdd) letIn(ctx context.Context, host string, out io.Writer) {
var addrs []string
if a, err := netip.ParseAddr(host); err == nil {
addrs = []string{a.String()}
} else {
ips, err := net.DefaultResolver.LookupNetIP(ctx, "ip4", host)
if err != nil {
fmt.Fprintf(out, " !! firewall: cannot resolve %s (%v); if it is on another network, "+
"list it in firewall_networks and run 'llmgw server init'\n", host, err)
return
}
for _, ip := range ips {
addrs = append(addrs, ip.Unmap().String())
}
}
added, err := firewall.Admit(ctx, pgha.Local{}, addrs)
switch {
case err != nil:
fmt.Fprintf(out, " !! firewall: could not let %s in: %v\n", host, err)
case len(added) > 0:
shown := make([]string, len(added))
for i, p := range added {
shown[i] = p.String()
}
fmt.Fprintf(out, " ok firewall: this control plane now lets in %s\n", strings.Join(shown, ", "))
}
}
func (na *nodeAdd) runPlain(ctx context.Context, o onboard.Options, title string, out io.Writer) (string, error) {
p := &plainProgress{out: out}
fmt.Fprintf(out, "%s\n", title)
+6
View File
@@ -59,8 +59,14 @@ func Root() *cobra.Command {
newSelfUpgradeCmd(),
newReleaseCmd(),
newJoinCmd(),
newFirewallCmd(),
newWipeCmd(),
newDocsCmd(),
)
// status, start, stop and restart: the whole system; service: this
// machine's services.
root.AddCommand(newSystemCmds()...)
root.AddCommand(newServiceCmd())
return root
}
+191 -4
View File
@@ -5,6 +5,7 @@ import (
"crypto/rand"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
@@ -16,6 +17,7 @@ import (
"slices"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
@@ -80,10 +82,13 @@ func newServerCmd() *cobra.Command {
RunE: func(cmd *cobra.Command, _ []string) error {
log := logger()
cfg, err := config.Load(configPath)
// server.yaml for this machine's own keys, the store's settings
// for the cluster's: the same on every control plane.
boot, err := loadClusterSettings(cmd.Context(), configPath, log)
if err != nil {
return err
}
cfg := boot.cfg
if listen != "" {
cfg.Listen = listen
}
@@ -166,7 +171,7 @@ func newServerCmd() *cobra.Command {
}
gate.SetJudge(rt.Judge, rt.GuardFormat)
rt.SetGuard(gate)
rt.SetGuardLibrary(cfg.Guard)
rt.SetGuardLibrary(gate.Library)
if err := rt.RegisterMetrics(gate); err != nil {
return fmt.Errorf("guard metrics: %w", err)
}
@@ -175,9 +180,14 @@ func newServerCmd() *cobra.Command {
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Cancelled to start again on changed settings.
ctx, cancelRun := context.WithCancel(ctx)
defer cancelRun()
var restartReason atomic.Pointer[string]
rt.Start(ctx)
defer rt.Stop()
rt.SetStopped(cfg.Serving.Stopped)
// Which cluster this is, to federated peers: the id in the hop
// header that stops a request going round two clusters that
@@ -215,6 +225,10 @@ func newServerCmd() *cobra.Command {
// The control plane replaces the static endpoint list: agents
// register, the reconciler places replicas, and Ready ones are
// published to the router (plan §6.4).
// The settings in force, for what reads them at each use.
var current atomic.Pointer[config.Server]
current.Store(&cfg)
var agentSvc *agentsvc.Service
var agentHandler http.Handler
var agentPath string
var admin *control.Controller
@@ -239,6 +253,25 @@ func newServerCmd() *cobra.Command {
return err
}
defer ctl.Close()
agentSvc = svc
// Stopped by the operators (llmgw stop): nothing placed.
ctl.Reconciler().SetStopped(cfg.Serving.Stopped)
// Whether nodes keep llmgw's firewall, as the settings say now.
svc.SetFirewall(func() *llmgwv1.Firewall {
c := current.Load()
from, to := c.EnginePorts()
return &llmgwv1.Firewall{Enabled: c.Firewall.Enabled, Networks: c.FirewallNetworks,
EnginePortsFrom: int32(from), EnginePortsTo: int32(to)} //nolint:gosec // ports
})
// What each node's agent runs on, as the settings say now.
svc.SetAgentSettings(func(node string) string {
b, err := config.Marshal(current.Load().Agents.For(node))
if err != nil {
log.Warn("could not write a node's agent settings", "node", node, "err", err)
return ""
}
return string(b)
})
ctl.Reconciler().SetNotifier(notifier)
admin = ctl
agentPath, agentHandler = svc.Handler()
@@ -341,12 +374,25 @@ func newServerCmd() *cobra.Command {
evals = newEvalService(cfg, rt, admin, log)
api.SetEval(evals)
if st := rt.Store(); st != nil && gate != nil {
api.SetGuard(&adminapi.GuardAdmin{Library: cfg.Guard, Store: st, Invalidate: gate.Invalidate})
api.SetGuard(&adminapi.GuardAdmin{Library: gate.Library, Store: st, Invalidate: gate.Invalidate,
JudgeErrors: gate.JudgeErrors, Ask: gate.AskJudge})
}
if st := rt.Store(); st != nil {
// Removing a node revokes its certificate.
api.SetRevoker(st.RevokeNode)
if agentSvc != nil {
api.SetAgentRestarter(agentSvc.RestartAgents)
}
api.SetAudit(st)
// Knowledge bases, with the cluster's own models to embed a
// probe (a collection's dimension) and an operator's search.
api.SetKB(&adminapi.KBAdmin{Store: st, Embed: embedder(rt.Replay)})
// The cluster's settings, each change checked against this
// machine's own keys before it is kept.
api.SetSettings(&adminapi.Settings{Store: st, LocalKeys: config.LocalKeys(),
Check: func(body []byte) error { return checkClusterSettings(configPath, body) }})
// And the same document on the portal, for administrators.
rt.SetSettingsCheck(func(body []byte) error { return checkClusterSettings(configPath, body) }, config.LocalKeys())
}
// Catalog changes need somewhere to persist, so this is wired
// only when there is a store. Without one, the endpoints say so
@@ -378,6 +424,17 @@ func newServerCmd() *cobra.Command {
} else {
admin.ReplaceCatalog(ctx, built)
}
// And again on becoming the leader: models added or
// removed through the leader before are in the store.
if l := admin.Leadership(); l != nil {
l.OnLead(func() {
if built, err := svc.Load(ctx); err != nil {
log.Error("could not read the stored models on becoming the leader", "err", err)
} else {
admin.ReplaceCatalog(ctx, built)
}
})
}
api.SetModels(svc)
catalogSvc = svc
// And the mirror page, so a model that has just been
@@ -476,6 +533,21 @@ func newServerCmd() *cobra.Command {
})
go serve.WatchCanaries(ctx, 30*time.Second)
adminAPI.SetServe(serve)
if gate != nil {
// A judge a policy names and the catalog lacks is
// fetched and served, on the leader.
keeper := &judgeKeeper{
library: gate.Library,
models: func() []catalog.Model { return admin.Catalog().Models() },
serve: serve,
leader: func() bool {
return admin.Leadership() == nil || admin.Leadership().IsLeader()
},
gpus: func() []adminapi.ClassUse { return adminAPI.Summary().GPUs },
log: log,
}
go keeper.run(ctx, 30*time.Second)
}
updateMetrics.Set(serve)
go serve.WatchUpdates(ctx, cfg.Mirror.UpdateCheck)
}
@@ -627,6 +699,71 @@ func newServerCmd() *cobra.Command {
}
}
// Every change to the settings in force: what can change live
// does, the rest starts this process again, which systemd brings
// back on them.
// And this machine's own firewall, through its sync unit.
if err := writeFirewallState(cfg); err != nil {
log.Warn("could not record the firewall's settings for this machine", "err", err)
}
watch := &settingsWatch{path: configPath, st: rt.Store(), cur: boot, log: log,
live: func(next config.Server, changed []string) bool {
if !onlyLive(changed) {
return false
}
current.Store(&next)
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "serving") }) {
rt.SetStopped(next.Serving.Stopped)
if admin != nil {
admin.Reconciler().SetStopped(next.Serving.Stopped)
}
log.Warn("the service is " + map[bool]string{true: "stopped", false: "started"}[next.Serving.Stopped] +
" by the cluster's settings")
}
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "firewall") }) {
if err := writeFirewallState(next); err != nil {
log.Warn("could not record the firewall's settings for this machine", "err", err)
}
if agentSvc != nil {
agentSvc.BroadcastFirewall()
}
}
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "agents") }) && agentSvc != nil {
// A moment apart per node, so off this goroutine.
go agentSvc.BroadcastAgentSettings()
}
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "guard") }) {
if gate == nil {
// Built at start, and only when there are policies.
return len(next.Guard.Policies) == 0
}
if err := gate.Replace(next.Guard); err != nil {
log.Error("the changed guard policies are not valid; the old ones stay in force", "err", err)
}
}
return true
},
restart: func(reason string) {
if os.Getenv("INVOCATION_ID") == "" {
log.Warn("the settings changed in a way this process applies only when it starts; "+
"it is not run by systemd, so start it again by hand", "why", reason)
return
}
// The leader last, so its standbys are already on the new
// settings when it hands over to one of them.
delay := time.Duration(0)
if admin != nil && admin.Leadership() != nil && admin.Leadership().IsLeader() && rt.Store() != nil {
if cps, err := rt.Store().ControlPlanes(ctx); err == nil && len(cps) > 1 {
delay = 30 * time.Second
}
}
log.Warn("starting again to apply the settings", "why", reason, "in", delay)
restartReason.Store(&reason)
time.AfterFunc(delay, cancelRun)
},
}
go watch.run(ctx)
g, gctx := errgroup.WithContext(ctx)
g.Go(func() error { return serve(apiSrv, apiLn) })
g.Go(func() error { return serve(adminSrv, adminLn) })
@@ -658,7 +795,12 @@ func newServerCmd() *cobra.Command {
_ = adminSrv.Shutdown(sctx)
return apiSrv.Shutdown(sctx)
})
return g.Wait()
err = g.Wait()
if r := restartReason.Load(); r != nil {
// Not a failure, but said as one, so systemd starts it again.
return fmt.Errorf("stopped to start again on changed settings (%s)", *r)
}
return err
},
}
@@ -756,6 +898,14 @@ func startControlPlane(ctx context.Context, cfg config.Server, cat *catalog.Live
ctl.SetEngineChoices(control.NewEngineChoices(ctx, st, log))
// Domains held for maintenance and labels set on nodes.
ctl.Facility().UseStore(ctx, st)
// A standby read these when it started; the leader before it may
// have changed them since.
lead.OnLead(func() {
if ch := ctl.EngineChoices(); ch != nil {
ch.Reload(ctx)
}
ctl.Facility().Reload(ctx)
})
log.Info("competing to be the control plane that places work",
"owner", lead.Owner())
}
@@ -1052,3 +1202,40 @@ func federationID(ctx context.Context, st *storepkg.DB) (string, error) {
// more of a secret than needed.
return secret[:16], nil
}
// embedder has a model embed one text through the gateway itself, the way a
// kept request is replayed: for the administrator's knowledge-base
// endpoints, not for anybody's traffic.
func embedder(replay func(ctx context.Context, path, model string, body []byte) (int, []byte, time.Duration, error)) func(context.Context, string, string) ([]float32, error) {
return func(ctx context.Context, model, text string) ([]float32, error) {
body, _ := json.Marshal(map[string]any{"model": model, "input": []string{text}})
status, out, _, err := replay(ctx, "/v1/embeddings", model, body)
if err != nil {
return nil, err
}
if status != http.StatusOK {
msg := strings.TrimSpace(string(out))
var e struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if json.Unmarshal(out, &e) == nil && e.Error.Message != "" {
msg = e.Error.Message
}
return nil, fmt.Errorf("%d: %s", status, msg)
}
var res struct {
Data []struct {
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
if err := json.Unmarshal(out, &res); err != nil {
return nil, err
}
if len(res.Data) != 1 || len(res.Data[0].Embedding) == 0 {
return nil, fmt.Errorf("%s answered no embedding", model)
}
return res.Data[0].Embedding, nil
}
}
+9 -1
View File
@@ -155,10 +155,18 @@ func (sa *serverAdd) run(ctx context.Context) error {
// firewall keeps the new control plane's internal ports to the networks it
// reaches this one on. Not fatal: it serves without one.
func (sa *serverAdd) firewall(ctx context.Context) error {
cfg, err := config.Load(filepath.Join(sa.etcDir, "server.yaml"))
cfg, err := loadEffectiveConfig(filepath.Join(sa.etcDir, "server.yaml"))
if err != nil {
return err
}
// What applies the cluster's firewall setting there when it changes.
if err := installFirewallSync(ctx, sa.remote); err != nil {
fmt.Fprintf(sa.out, " !! firewall: %v\n", err)
}
if !cfg.Firewall.Enabled {
sa.kept("firewall: off, as the cluster's settings say (firewall.enabled)")
return nil
}
self, err := sourceIPToward(sa.ip)
if err != nil {
return err
+68 -10
View File
@@ -288,12 +288,12 @@ func (in *serverInit) judgeWanted() bool {
// configuration written before init installed one is never changed, so the
// model is not fetched for nothing.
func (in *serverInit) configJudgesWith(model string) bool {
cfg, err := config.Load(in.configPath())
cfg, err := loadEffectiveConfig(in.configPath())
if err != nil {
return false
}
for _, p := range cfg.Guard.Policies {
if p.Judge != nil && p.Judge.Model == model {
if slices.Contains(p.Judge.Judges(), model) {
return true
}
}
@@ -308,18 +308,21 @@ func guardSection(judge bool) string {
# judge: {model: ` + guardModelName + `, input: block, output: block, ...}
`
if judge {
j = ` # A judge model reads what patterns cannot: violence, weapons, abuse,
# self-harm. Two replicas run on CPUs with AVX2, about half a second
# for a short text; an answer is read every 600 characters as it
# streams. With fail: closed, a request the judge cannot read within
j = ` # A judge model reads what patterns cannot: violence, crime, sexual
# content, self-harm, jailbreaks. Two replicas run on CPUs, about two
# seconds for a short text; an answer is read every 600 characters as
# it streams. With fail: closed, a request no judge can read within
# latency_budget is refused (503); open serves it, flagged, unjudged.
# Defamation (S5), specialized advice (S6), intellectual property (S8)
# and elections (S13) are left out: they refuse too much ordinary work.
# PII is the patterns' (above), and copyright, unethical acts and
# politically sensitive topics are left out: they refuse too much
# ordinary work. Controversial counts as safe; controversial: unsafe
# makes it count. With a GPU: llmgw ctl guard judge enable --gpu, then
# llmgw ctl guard judge use ` + defaultGPUJudge + ` ` + guardModelName + `.
judge:
model: ` + guardModelName + `
models: [` + guardModelName + `]
input: block
output: block
categories: [S1, S2, S3, S4, S7, S9, S10, S11, S12]
categories: [Violent, Non-violent Illegal Acts, Sexual Content or Sexual Acts, Suicide & Self-Harm, Jailbreak]
latency_budget: 5s
`
}
@@ -821,3 +824,58 @@ Before people outside this network use it:
# in server.yaml, and copy them off this machine
`)
}
// primaryAddr is the address other machines most likely reach this one on:
// the source of its default route, else the first address on an interface
// that is not a container's or a bridge's. The first address of all is not
// it on a machine with Docker, whose 172.17.0.1 a node takes for its own.
func primaryAddr() string {
if out, err := exec.Command("ip", "-4", "route", "get", "1.1.1.1").Output(); err == nil {
if src := routeSource(string(out)); src != "" {
return src
}
}
ifaces, err := net.Interfaces()
if err == nil {
for _, ifc := range ifaces {
if ifc.Flags&net.FlagUp == 0 || virtualInterface(ifc.Name) {
continue
}
addrs, _ := ifc.Addrs()
for _, a := range addrs {
if n, ok := a.(*net.IPNet); ok && n.IP.To4() != nil && n.IP.IsGlobalUnicast() {
return n.IP.String()
}
}
}
}
if ips := localAddrs(); len(ips) > 0 {
return ips[0].String()
}
return ""
}
// routeSource reads the src of `ip route get`.
func routeSource(out string) string {
f := strings.Fields(out)
for i := 0; i+1 < len(f); i++ {
if f[i] == "src" {
if ip := net.ParseIP(f[i+1]); ip != nil && !ip.IsLoopback() {
return ip.String()
}
}
}
return ""
}
// virtualInterface is an interface containers, bridges or overlays made,
// whose address no other machine reaches this one on.
func virtualInterface(name string) bool {
for _, p := range []string{"docker", "br-", "virbr", "veth", "cni", "flannel", "cali", "vxlan",
"kube", "lxc", "lxd", "podman", "cilium", "weave", "tun", "tap"} {
if strings.HasPrefix(name, p) {
return true
}
}
return false
}
+21 -2
View File
@@ -12,6 +12,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/bundle"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
@@ -60,7 +61,7 @@ func (in *serverInit) offline() bool {
if in.bundle != "" {
return true
}
cfg, err := config.Load(in.configPath())
cfg, err := loadEffectiveConfig(in.configPath())
return err == nil && cfg.Offline.Enabled()
}
@@ -76,7 +77,7 @@ func (in *serverInit) mirrorRoot() string {
// server.yaml that the cluster is offline. With a mirror on another
// machine, the files are imported there; here only the checksums are kept.
func (in *serverInit) ensureBundle(ctx context.Context) error {
cfg, err := config.Load(in.configPath())
cfg, err := loadEffectiveConfig(in.configPath())
if err != nil {
return err
}
@@ -129,6 +130,24 @@ func (in *serverInit) ensureBundle(ctx context.Context) error {
if sums == config.DefaultOfflineChecksums {
sums = ""
}
// In the cluster's settings once the store holds them; in server.yaml
// before, where the first start brings it in.
stored, err := changeStoredSettings(in.configPath(), "offline, from server init --bundle", func(doc *settings.Doc) error {
if err := doc.SetYAML([]string{"offline", "mirror"}, files); err != nil {
return err
}
if sums != "" {
return doc.SetYAML([]string{"offline", "checksums"}, sums)
}
return nil
})
if err != nil {
return err
}
if stored {
in.done("the cluster's settings: offline, nodes install from the mirror at %s", files)
return nil
}
if err := appendOfflineBlock(in.configPath(), files, sums); err != nil {
return err
}
+10 -14
View File
@@ -41,11 +41,13 @@ const (
starterSource = "hf:Qwen/Qwen2.5-0.5B-Instruct-GGUF/qwen2.5-0.5b-instruct-q4_k_m.gguf"
starterVersion = "starter"
// The guardrails' judge: Meta's Llama Guard 3 1B, which reads a prompt
// or an answer and says whether it is unsafe and why. Q4_K_M is about
// 900 MB and judges a short text in about half a second on a CPU.
guardModelName = "llama-guard-3-1b"
guardModelSource = "hf:QuantFactory/Llama-Guard-3-1B-GGUF/Llama-Guard-3-1B.Q4_K_M.gguf"
// The guardrails' judge: Qwen3Guard-Gen 0.6B, which reads a prompt or an
// answer and says whether it is safe, controversial or unsafe, and why.
// Q8_0 is 0.8 GB and judges a short text in about two seconds on two CPU
// threads. It replaced Llama Guard 3 1B, which refused about one in two
// ordinary coding requests ("continue").
guardModelName = defaultJudge
guardModelSource = "hf:mradermacher/Qwen3Guard-Gen-0.6B-GGUF/Qwen3Guard-Gen-0.6B.Q8_0.gguf"
guardModelVersion = "guard"
// localLabel marks the control plane's own node, so the starter model is
@@ -86,8 +88,8 @@ func (in *serverInit) reachableAddr() string {
if len(in.hosts) > 0 {
return in.hosts[0]
}
if ips := localAddrs(); len(ips) > 0 {
return ips[0].String()
if a := primaryAddr(); a != "" {
return a
}
name, _ := os.Hostname()
return name
@@ -443,12 +445,6 @@ func starterModel(source string, gpuClasses []string, pinned bool) catalog.Model
}
}
// llamaGuard3Categories name Llama Guard 3's S1 to S13, so a verdict is
// recorded as "Violent Crimes" rather than "S1".
var llamaGuard3Categories = []string{"Violent Crimes", "Non-Violent Crimes", "Sex-Related Crimes",
"Child Sexual Exploitation", "Defamation", "Specialized Advice", "Privacy", "Intellectual Property",
"Indiscriminate Weapons", "Hate", "Suicide & Self-Harm", "Sexual Content", "Elections"}
// guardModel is the judge's catalog entry: CPU only, so it takes no card from
// the models people use, on whichever node has room.
func guardModel(source string) catalog.Model {
@@ -457,7 +453,7 @@ func guardModel(source string) catalog.Model {
Description: "The guardrails' judge: reads prompts and answers for unsafe content. Not offered to clients.",
Source: source,
Role: catalog.RoleGuard,
Guard: &catalog.GuardModel{Format: "llamaguard3", Categories: llamaGuard3Categories},
Guard: &catalog.GuardModel{Format: "qwen3guard"},
// Two: every judged request waits on it, and with fail: closed a
// judge that is not there refuses everything.
Scaling: catalog.Scaling{Min: 2, Max: 2},
+13 -3
View File
@@ -7,12 +7,14 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
)
func TestTheInitAgentUnitIsTheShippedUnit(t *testing.T) {
@@ -150,9 +152,17 @@ func TestTheInstalledConfigurationIsHardenedAndLoads(t *testing.T) {
if (p.Judge != nil) != judge {
t.Errorf("judge=%v: the policy's judge is %+v", judge, p.Judge)
}
if judge && (p.Judge.Model != guardModelName || p.Judge.Input != "block") {
if judge && (!slices.Equal(p.Judge.Judges(), []string{guardModelName}) || p.Judge.Input != "block") {
t.Errorf("judge %+v", p.Judge)
}
// A category the judge never names would never count.
if judge {
for _, c := range p.Judge.Categories {
if !slices.Contains(guard.DefaultCategories("qwen3guard"), c) {
t.Errorf("the baseline counts %q, which Qwen3Guard does not name", c)
}
}
}
if cfg.Auth.MaxKeyTTL != 90*24*time.Hour || cfg.Auth.SignupOpen || cfg.DebugPprof || cfg.AdminRemote {
t.Errorf("not hardened: ttl %v signup %v pprof %v remote %v",
cfg.Auth.MaxKeyTTL, cfg.Auth.SignupOpen, cfg.DebugPprof, cfg.AdminRemote)
@@ -161,13 +171,13 @@ func TestTheInstalledConfigurationIsHardenedAndLoads(t *testing.T) {
}
func TestTheGuardModelIsAValidJudge(t *testing.T) {
m := guardModel("http://10.0.0.5:8081/llama-guard-3-1b/guard/g.gguf")
m := guardModel("http://10.0.0.5:8081/qwen3guard-0.6b/guard/g.gguf")
c, err := catalog.New([]catalog.Model{m})
if err != nil {
t.Fatalf("not a valid catalog entry: %v", err)
}
got, _ := c.Resolve(guardModelName)
if !got.IsGuard() || got.Guard.Format != "llamaguard3" || len(got.Guard.Categories) != 13 || got.Scaling.Min != 2 {
if !got.IsGuard() || got.Guard.Format != "qwen3guard" || got.Scaling.Min != 2 {
t.Errorf("%+v", got)
}
for _, p := range got.Profiles {
+16 -1
View File
@@ -19,10 +19,19 @@ import (
// the engines and etcd when they are here. The client API is not among
// them; it is meant to be reached.
func (in *serverInit) ensureFirewall(ctx context.Context) error {
cfg, err := config.Load(in.configPath())
cfg, err := loadEffectiveConfig(in.configPath())
if err != nil {
return err
}
// What applies the cluster's setting here when it changes: the server
// writes it, as llmgw; this applies it, as root.
if err := in.ensureFirewallSync(ctx); err != nil {
return err
}
if !cfg.Firewall.Enabled {
in.kept("firewall: off, as the cluster's settings say (firewall.enabled)")
return nil
}
ports := controlPlanePorts(cfg)
if !in.noMirror && in.mirrorURL == "" {
ports = append(ports, firewall.MirrorFiles)
@@ -122,3 +131,9 @@ func loopbackListen(listen string) bool {
ip := net.ParseIP(host)
return host == "localhost" || (ip != nil && ip.IsLoopback())
}
// ensureFirewallSync installs the units that apply the cluster's firewall
// setting on this control plane.
func (in *serverInit) ensureFirewallSync(ctx context.Context) error {
return installFirewallSync(ctx, firewall.Local{})
}
+60 -2
View File
@@ -370,6 +370,8 @@ func (in *serverInit) setupPostgres(ctx context.Context) (string, error) {
in.done("PostgreSQL sized for a machine it shares (shared_buffers 96MB, 40 connections)")
}
in.setupPgvector(ctx, discovery.DiscoverHost())
// Proof the address works, before anything is moved into it.
address := "@" + in.dsnPath()
s, err := store.Open(ctx, address)
@@ -482,8 +484,17 @@ func lastLines(b []byte, n int) string {
// psql runs one statement as the postgres superuser, over the local socket.
func (in *serverInit) psql(ctx context.Context, query string) (string, error) {
cmd := exec.CommandContext(ctx, "runuser", "-u", "postgres", "--",
in.pg.tool("psql"), "-v", "ON_ERROR_STOP=1", "-qAtX", "-c", query)
return in.psqlIn(ctx, "", query)
}
// psqlIn runs a statement as postgres in one database, or in postgres's
// default one when db is "".
func (in *serverInit) psqlIn(ctx context.Context, db, query string) (string, error) {
args := []string{"-v", "ON_ERROR_STOP=1", "-qAtX", "-c", query}
if db != "" {
args = append(args, "-d", db)
}
cmd := exec.CommandContext(ctx, "runuser", append([]string{"-u", "postgres", "--", in.pg.tool("psql")}, args...)...)
// postgres cannot enter root's home, and psql says so on every call.
cmd.Dir = "/"
out, err := cmd.CombinedOutput()
@@ -538,3 +549,50 @@ func (in *serverInit) stopService(ctx context.Context) (bool, error) {
in.done("llmgw-server stopped while its store moves")
return true, nil
}
// pgvectorPackages are the package names of pgvector for a PostgreSQL major
// version: Debian's and Ubuntu's (and PGDG's apt repository) name it after the
// server, PGDG's rpm repository after the version alone.
func pgvectorPackages(major int) map[string][]string {
return map[string][]string{
"deb": {fmt.Sprintf("postgresql-%d-pgvector", major)},
"rpm": {fmt.Sprintf("pgvector_%d", major)},
}
}
// setupPgvector installs pgvector and enables it in llmgw's database, for
// knowledge bases. Not a reason to stop: without it everything else works,
// and the store says why knowledge bases are off when somebody asks for one.
func (in *serverInit) setupPgvector(ctx context.Context, h discovery.Host) {
warn := func(format string, a ...any) {
fmt.Fprintf(in.out, " !! pgvector: "+format+"; knowledge bases stay off\n", a...)
}
if on, err := in.psqlIn(ctx, pgDatabase, "SELECT 1 FROM pg_extension WHERE extname = 'vector'"); err == nil && on == "1" {
in.kept("pgvector in database %s", pgDatabase)
return
}
have, err := in.psqlBool(ctx, "SELECT 1 FROM pg_available_extensions WHERE name = 'vector'")
if err != nil {
warn("%v", err)
return
}
if !have {
num, err := in.psql(ctx, "SHOW server_version_num")
n, convErr := strconv.Atoi(num)
if err != nil || convErr != nil || h.Family() == "" {
warn("cannot tell which package to install (version %q, %s)", num, h.OSID)
return
}
pkgs := pgvectorPackages(n / 10000)
pi := &provision.Installer{Host: h, Log: slog.Default()}
if err := pi.InstallPackages(ctx, pkgs); err != nil {
warn("installing %s: %v", strings.Join(pkgs[h.Family()], " "), err)
return
}
}
if _, err := in.psqlIn(ctx, pgDatabase, "CREATE EXTENSION IF NOT EXISTS vector"); err != nil {
warn("%v", err)
return
}
in.done("pgvector enabled in database %s", pgDatabase)
}
+19
View File
@@ -172,3 +172,22 @@ func TestInitKeepsCertificatesFromAnAuthorityElsewhere(t *testing.T) {
t.Error("--host cannot be honoured without the authority, and should say so")
}
}
func TestTheAddressNodesReachIsNotAContainersBridge(t *testing.T) {
if got := routeSource("1.1.1.1 via 10.104.130.129 dev ens192 src 10.104.130.140 uid 0\n cache\n"); got != "10.104.130.140" {
t.Errorf("route source %q", got)
}
if got := routeSource("unreachable"); got != "" {
t.Errorf("no route: %q", got)
}
for _, name := range []string{"docker0", "br-3f2a", "virbr0", "veth12ab", "cni0", "flannel.1"} {
if !virtualInterface(name) {
t.Errorf("%s taken for a real interface", name)
}
}
for _, name := range []string{"eth0", "ens192", "enp3s0", "bond0", "ib0"} {
if virtualInterface(name) {
t.Errorf("%s taken for a virtual one", name)
}
}
}
+187
View File
@@ -0,0 +1,187 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
"github.com/spf13/cobra"
)
// llmgwServices are the units llmgw runs as on a machine, in the order they
// start: the store's clients after the store, the server before the agent
// that dials it.
var llmgwServices = []string{"llmgw-mirror", "llmgw-server", "llmgw-agent"}
// unitDir is where llmgw's units are installed.
var unitDir = "/etc/systemd/system"
// serviceRunner runs systemctl; a variable so tests stand in for it.
var serviceRunner = func(ctx context.Context, args ...string) (string, error) {
out, err := exec.CommandContext(ctx, "systemctl", args...).CombinedOutput() //nolint:gosec // fixed command
return strings.TrimSpace(string(out)), err
}
// installedServices are the llmgw units this machine has, or those named.
func installedServices(ctx context.Context, names []string) ([]string, error) {
var want []string
for _, n := range names {
if !strings.HasPrefix(n, "llmgw-") {
n = "llmgw-" + n
}
want = append(want, n)
}
var have []string
for _, u := range llmgwServices {
if _, err := os.Stat(unitDir + "/" + u + ".service"); err != nil {
continue
}
if len(want) > 0 && !hasUnit(want, u) {
continue
}
have = append(have, u)
}
for _, w := range want {
if !hasUnit(have, w) {
return nil, fmt.Errorf("%s is not installed here (this machine has: %s)", w, strings.Join(present(), ", "))
}
}
if len(have) == 0 {
return nil, errors.New("no llmgw service is installed on this machine")
}
return have, nil
}
func present() []string {
var out []string
for _, u := range llmgwServices {
if _, err := os.Stat(unitDir + "/" + u + ".service"); err == nil {
out = append(out, strings.TrimPrefix(u, "llmgw-"))
}
}
if len(out) == 0 {
return []string{"none"}
}
return out
}
func hasUnit(list []string, s string) bool {
for _, x := range list {
if x == s {
return true
}
}
return false
}
// newServiceCmd is llmgw service: this machine's llmgw services, one by one.
// The whole system is llmgw status, start, stop and restart.
func newServiceCmd() *cobra.Command {
c := &cobra.Command{
Use: "service",
Short: "This machine's llmgw services: status, start, stop, restart",
Long: "The control plane (server), node agent (agent) and model mirror (mirror) on\n" +
"this machine alone, by systemd. The whole system is 'llmgw status', 'llmgw\n" +
"stop', 'llmgw start' and 'llmgw restart', run on a control plane.",
}
c.AddCommand(serviceVerbs()...)
return c
}
func serviceVerbs() []*cobra.Command {
status := &cobra.Command{
Use: "status [server|agent|mirror]...",
Short: "Whether llmgw's services on this machine run",
Long: "Each llmgw service this machine has — the control plane (server), the\n" +
"node agent (agent), the model mirror (mirror) — and whether it runs.",
RunE: func(cmd *cobra.Command, args []string) error {
units, err := localUnits(cmd, args)
if err != nil {
return err
}
out := cmd.OutOrStdout()
for _, u := range units {
state, _ := serviceRunner(cmd.Context(), "is-active", u)
since, _ := serviceRunner(cmd.Context(), "show", u, "-p", "ActiveEnterTimestamp", "--value")
fmt.Fprintf(out, "%-14s %-10s %s\n", strings.TrimPrefix(u, "llmgw-"), state, since)
}
return nil
},
}
verb := func(use, short, long, action string, reverse bool) *cobra.Command {
var engines bool
c := &cobra.Command{
Use: use + " [server|agent|mirror]...",
Short: short,
Long: long,
RunE: func(cmd *cobra.Command, args []string) error {
if os.Geteuid() != 0 {
return errors.New("starting and stopping services needs root: run it with sudo")
}
units, err := localUnits(cmd, args)
if err != nil {
return err
}
if reverse {
// The agent before the server it dials, the server before
// the mirror it reads.
for i, j := 0, len(units)-1; i < j; i, j = i+1, j-1 {
units[i], units[j] = units[j], units[i]
}
}
out := cmd.OutOrStdout()
for _, u := range units {
if msg, err := serviceRunner(cmd.Context(), action, u); err != nil {
return fmt.Errorf("%s %s: %v %s", action, u, err, msg)
}
state, _ := serviceRunner(cmd.Context(), "is-active", u)
fmt.Fprintf(out, "%-14s %s\n", strings.TrimPrefix(u, "llmgw-"), state)
}
if action == "stop" && hasUnit(units, "llmgw-agent") {
if engines {
_, _ = exec.CommandContext(cmd.Context(), "pkill", "-u", "llmgw-engine").CombinedOutput() //nolint:gosec // fixed command
fmt.Fprintln(out, "engines stopped (every process of llmgw-engine)")
} else {
fmt.Fprintln(out, "\nThe engines keep serving: they outlive the agent, which takes them over again when it starts.\n"+
"--engines stops them too; the control plane then places their replicas elsewhere.")
}
}
if action == "stop" && hasUnit(units, "llmgw-server") {
fmt.Fprintln(out, "A standby control plane, where there is one, takes over within seconds.")
}
return nil
},
}
if action == "stop" {
c.Flags().BoolVar(&engines, "engines", false, "also stop the engines this node runs")
}
return c
}
return []*cobra.Command{
status,
verb("start", "Start llmgw's services on this machine",
"Starts each llmgw service this machine has, or those named: the mirror, the\n"+
"control plane, the node agent, in that order.", "start", false),
verb("stop", "Stop llmgw's services on this machine",
"Stops each llmgw service this machine has, or those named: the agent, the\n"+
"control plane, the mirror, in that order. The engines a node runs keep\n"+
"serving unless --engines is given; a standby control plane takes over from\n"+
"a leader stopped. They stay stopped until 'llmgw start', or the machine\n"+
"boots.", "stop", true),
verb("restart", "Restart llmgw's services on this machine",
"Restarts each llmgw service this machine has, or those named. Engines keep\n"+
"serving and are taken over again by the agent; a control plane's settings\n"+
"and server.yaml are read anew.", "restart", false),
}
}
func localUnits(cmd *cobra.Command, args []string) ([]string, error) {
if runtime.GOOS != "linux" {
return nil, fmt.Errorf("llmgw's services run under systemd on Linux; this is %s", runtime.GOOS)
}
return installedServices(cmd.Context(), args)
}
+137
View File
@@ -0,0 +1,137 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
func TestTheServicesHereAreFoundAndAskedAbout(t *testing.T) {
old, oldRun := unitDir, serviceRunner
unitDir = t.TempDir()
t.Cleanup(func() { unitDir, serviceRunner = old, oldRun })
for _, u := range []string{"llmgw-server", "llmgw-agent"} {
_ = os.WriteFile(filepath.Join(unitDir, u+".service"), []byte("[Unit]"), 0o644)
}
var asked []string
serviceRunner = func(_ context.Context, args ...string) (string, error) {
asked = append(asked, strings.Join(args, " "))
if args[0] == "is-active" {
return "active", nil
}
return "Wed 2026-10-07 12:00:00 UTC", nil
}
units, err := installedServices(context.Background(), nil)
if err != nil || strings.Join(units, ",") != "llmgw-server,llmgw-agent" {
t.Fatalf("%v %v", units, err)
}
if _, err := installedServices(context.Background(), []string{"mirror"}); err == nil ||
!strings.Contains(err.Error(), "server, agent") {
t.Errorf("a service not here: %v", err)
}
if u, err := installedServices(context.Background(), []string{"agent"}); err != nil || len(u) != 1 {
t.Errorf("one named: %v %v", u, err)
}
if runtime.GOOS != "linux" {
return
}
var out bytes.Buffer
c := serviceVerbs()[0]
c.SetOut(&out)
c.SetContext(context.Background())
if err := c.RunE(c, nil); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "server") || !strings.Contains(out.String(), "active") {
t.Errorf("%s", out.String())
}
}
func TestStopSetsTheClustersServingStopped(t *testing.T) {
var put adminapi.SettingsRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.URL.Path == "/api/v1/summary":
_ = json.NewEncoder(w).Encode(adminapi.Summary{})
case r.Method == "GET" && r.URL.Path == "/api/v1/settings":
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 3, Body: "router:\n retries: 2\n"})
case r.Method == "PUT" && r.URL.Path == "/api/v1/settings":
_ = json.NewDecoder(r.Body).Decode(&put)
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 4, Body: put.Body})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
dir := t.TempDir()
cfg := filepath.Join(dir, "server.yaml")
_ = os.WriteFile(filepath.Join(dir, "token"), []byte("t"), 0o600)
_ = os.WriteFile(cfg, []byte("listen: \":8080\"\nagent_insecure: true\nadmin_listen: "+
strings.TrimPrefix(srv.URL, "http://")+"\nadmin_remote: true\nadmin_token_file: "+filepath.Join(dir, "token")+"\n"), 0o600)
var stop *cobra.Command
for _, c := range newSystemCmds() {
if c.Name() == "stop" {
stop = c
}
}
var out bytes.Buffer
stop.SetOut(&out)
stop.SetArgs([]string{"--config", cfg, "--note", "maintenance"})
if err := stop.ExecuteContext(context.Background()); err != nil {
t.Fatalf("%v: %s", err, out.String())
}
if put.Base != 3 || !strings.Contains(put.Body, "stopped: true") || !strings.Contains(put.Body, "retries: 2") ||
put.Note != "maintenance" {
t.Errorf("sent %+v", put)
}
if !strings.Contains(out.String(), "stopping") {
t.Errorf("%s", out.String())
}
}
// Started while the control plane has not yet taken the setting up: every
// model of a stopped system has the nothing it asks for, which is not
// serving.
func TestStartWaitsForTheControlPlaneToServe(t *testing.T) {
old := servicePoll
servicePoll = time.Millisecond
t.Cleanup(func() { servicePoll = old })
answers := []adminapi.Summary{
{Stopped: true, Models: adminapi.ModelTotals{Total: 2, Satisfied: 2}},
{Models: adminapi.ModelTotals{Total: 2, Satisfied: 0}},
{Models: adminapi.ModelTotals{Total: 2, Satisfied: 2}},
}
asked := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_ = json.NewEncoder(w).Encode(answers[min(asked, len(answers)-1)])
asked++
}))
defer srv.Close()
var out bytes.Buffer
cmd := &cobra.Command{}
cmd.SetOut(&out)
cmd.SetContext(context.Background())
c := &ctlClient{base: srv.URL, http: srv.Client()}
if err := waitForService(cmd, func() *ctlClient { return c }, false); err != nil {
t.Fatal(err)
}
if asked != 3 {
t.Errorf("returned after %d answers: %s", asked, out.String())
}
}
+13
View File
@@ -153,6 +153,19 @@ func printSummary(cmd *cobra.Command, s adminapi.Summary) {
fmt.Fprintf(out, "STORE %s\n", strings.Join(parts, ", "))
}
if s.Stopped {
fmt.Fprintln(out, "SERVICE STOPPED: no model runs and requests are answered 503 (llmgw start)")
}
if s.GuardOff {
fmt.Fprintln(out, "GUARD off: no request is checked, whatever the policies say (llmgw ctl guard on)")
}
if len(s.Judges) > 0 {
fmt.Fprintln(out, "\nGUARDRAILS' JUDGE")
for _, j := range s.Judges {
fmt.Fprintf(out, " %s: %s\n", j.Model, j.Problem)
}
}
if len(s.Parked) > 0 {
fmt.Fprintln(out, "\nPARKED — failed every time they started; not retried until fixed")
for _, p := range s.Parked {
+232
View File
@@ -0,0 +1,232 @@
package cli
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
)
// systemClient is the admin API of the control plane on this machine, as its
// server.yaml says, with its token.
func systemClient(configPath string) func() *ctlClient {
return func() *ctlClient {
addr := "127.0.0.1:9090"
var tokens []string
if cfg, ok := loadServerConfig(configPath); ok {
if cfg.AdminListen != "" {
addr = cfg.AdminListen
if strings.HasPrefix(addr, ":") {
addr = "127.0.0.1" + addr
}
}
tokens = append(tokens, adminTokenPath(configPath, cfg))
}
return newCtlClient(addr, tokens...)
}
}
// newSystemCmds are llmgw status, stop, start and restart: the whole system,
// from a control plane.
func newSystemCmds() []*cobra.Command {
var configPath string
client := systemClient("/etc/llmgw/server.yaml")
bind := func(c *cobra.Command) *cobra.Command {
c.Flags().StringVar(&configPath, "config", "/etc/llmgw/server.yaml", "this control plane's configuration")
c.PreRun = func(*cobra.Command, []string) { client = systemClient(configPath) }
return c
}
call := func() *ctlClient { return client() }
status := bind(&cobra.Command{
Use: "status",
Short: "The whole system: whether it serves, its control planes, nodes, models and store",
Long: "Run on a control plane. Says whether the service is stopped (llmgw stop),\n" +
"and everything 'llmgw ctl status' does. One machine's services are\n" +
"'llmgw service status'.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var s adminapi.Summary
if err := call().get(cmd.Context(), "/api/v1/summary", &s); err != nil {
return notAControlPlane(err)
}
printSummary(cmd, s)
return nil
},
})
switchService := func(stop bool) *cobra.Command {
use, short, long := "start", "Start the whole system serving again, after llmgw stop",
"Every model comes back as its catalog entry asks: placed, loaded and\n"+
"serving again, on every control plane within seconds. Run on a control\n"+
"plane. The services themselves are never stopped by llmgw stop; a\n"+
"machine's own are started with 'llmgw service start'."
if stop {
use, short, long = "stop", "Stop the whole system serving: every model taken down, requests answered 503",
"Every model's replicas are taken down, freeing every GPU, and every\n"+
"request for a model is answered 503 (system_stopped) until 'llmgw start'.\n"+
"The control planes, agents and mirror keep running, so the console, the\n"+
"admin pages and llmgw start still work, and no model's definition is\n"+
"changed. In force on every control plane within seconds; kept in the\n"+
"cluster's settings (serving.stopped), so a restart does not undo it."
}
var note string
var wait bool
c := bind(&cobra.Command{
Use: use,
Short: short,
Long: long,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if note == "" {
note = "llmgw " + use
}
var s adminapi.Summary
if err := call().get(cmd.Context(), "/api/v1/summary", &s); err != nil {
return notAControlPlane(err)
}
if s.Stopped == stop {
fmt.Fprintf(cmd.OutOrStdout(), "the system is already %s\n", map[bool]string{true: "stopped", false: "serving"}[stop])
return nil
}
if err := changeSettings(cmd, call, note, func(doc *settings.Doc) error {
return doc.SetYAML([]string{"serving", "stopped"}, fmt.Sprint(stop))
}); err != nil {
return err
}
out := cmd.OutOrStdout()
if !wait {
if stop {
fmt.Fprintln(out, "stopping: every model's replicas are being taken down; llmgw status follows it")
} else {
fmt.Fprintln(out, "starting: every model is placed again as it asks; llmgw status follows it")
}
return nil
}
return waitForService(cmd, call, stop)
},
})
c.Flags().StringVar(&note, "note", "", "why, kept with the settings' version and in the audit")
c.Flags().BoolVar(&wait, "wait", false, "wait until every replica is down (stop) or the models have theirs (start)")
return c
}
var sshUser string
restart := bind(&cobra.Command{
Use: "restart",
Short: "Restart every control plane and node agent; the engines keep serving",
Long: "Every node's agent starts again, then the other control planes over ssh\n" +
"as --ssh-user, then this one — the leader last, so it hands over to a\n" +
"standby already restarted. The engines keep serving throughout and are\n" +
"taken over again; nothing is placed anew. Run on a control plane, with\n" +
"sudo. One machine's services are 'llmgw service restart'.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if os.Geteuid() != 0 {
return errors.New("restarting this control plane needs root: run it with sudo")
}
ctx, out := cmd.Context(), cmd.OutOrStdout()
var s adminapi.Summary
if err := call().get(ctx, "/api/v1/summary", &s); err != nil {
return notAControlPlane(err)
}
var agents adminapi.AgentRestartView
if err := call().post(ctx, "/api/v1/agents/restart", []byte("{}"), &agents); err != nil {
return fmt.Errorf("restarting the agents: %w", err)
}
fmt.Fprintf(out, "agents %d told to restart: %s\n", len(agents.Nodes), strings.Join(agents.Nodes, ", "))
// The other control planes, standbys before a leader.
var others []adminapi.ControlPlane
for _, cp := range s.ControlPlanes.Members {
if !cp.This {
others = append(others, cp)
}
}
// One that cannot be reached is said and left: the rest of the
// system still restarts, and the leader still goes last.
var failed []string
for pass := 0; pass < 2; pass++ {
for _, cp := range others {
leading := cp.State == "leading"
if leading != (pass == 1) {
continue
}
r := &onboard.SSH{Addr: cp.Address, User: sshUser}
if res, err := r.Run(ctx, "sudo systemctl restart llmgw-server", nil); err != nil {
fmt.Fprintf(out, "%-10s NOT restarted: ssh as %q: %v %s\n", cp.Name, sshUser, err, strings.TrimSpace(string(res)))
failed = append(failed, cp.Name+" ("+cp.Address+")")
continue
}
fmt.Fprintf(out, "%-10s restarted (%s)\n", cp.Name, cp.State)
time.Sleep(5 * time.Second)
}
}
if _, err := os.Stat(unitDir + "/llmgw-server.service"); err == nil {
fmt.Fprintln(out, "this restarting")
if b, err := exec.CommandContext(ctx, "systemctl", "restart", "llmgw-server").CombinedOutput(); err != nil { //nolint:gosec // fixed command
return fmt.Errorf("restarting this control plane: %v: %s", err, strings.TrimSpace(string(b)))
}
fmt.Fprintln(out, "this restarted")
}
fmt.Fprintln(out, "\nllmgw status shows the nodes coming back; their replicas are taken over as they are.")
if len(failed) > 0 {
return fmt.Errorf("not restarted: %s; run `sudo llmgw service restart server` there, "+
"or give --ssh-user someone this machine's root can log in as", strings.Join(failed, ", "))
}
return nil
},
})
restart.Flags().StringVar(&sshUser, "ssh-user", os.Getenv("SUDO_USER"), "who to log in to the other control planes as")
return []*cobra.Command{status, switchService(true), switchService(false), restart}
}
// servicePoll is how often --wait asks; a variable so tests need not wait.
var servicePoll = 5 * time.Second
// waitForService waits until no replica runs (stopped) or every model has
// the replicas it asks for (started).
//
// Only once the control plane says it is in the state asked for: it takes the
// setting up at its next look at the store, seconds later, and until then a
// stopped system's models, which ask for nothing, all have what they ask for —
// so llmgw start --wait returned at once, with nothing started.
func waitForService(cmd *cobra.Command, call func() *ctlClient, stop bool) error {
deadline := time.Now().Add(15 * time.Minute)
for time.Now().Before(deadline) {
var s adminapi.Summary
if err := call().get(cmd.Context(), "/api/v1/summary", &s); err == nil && s.Stopped == stop {
if stop && s.Replicas.Ready+s.Replicas.Starting+s.Replicas.Draining == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "stopped: no replica runs")
return nil
}
if !stop && s.Models.Satisfied >= s.Models.Total {
fmt.Fprintf(cmd.OutOrStdout(), "serving: %d of %d models have the replicas they ask for\n",
s.Models.Satisfied, s.Models.Total)
return nil
}
}
select {
case <-cmd.Context().Done():
return cmd.Context().Err()
case <-time.After(servicePoll):
}
}
return errors.New("not there within fifteen minutes; llmgw status says where it stands")
}
// notAControlPlane is a failure to reach the admin API, said as what it
// most often means.
func notAControlPlane(err error) error {
return fmt.Errorf("%w\n(the whole system is stopped and started from a control plane; this machine's own "+
"services are 'llmgw service status|start|stop|restart')", err)
}
+23 -3
View File
@@ -34,10 +34,11 @@ import (
// wrapper — left it in the content. A differently trained model fixes that. A
// bigger one from the same family does not: qwen2.5-coder-7b invented
// <function-call> and failed the same way.
func newToolCheckCmd() *cobra.Command {
func newToolCheckCmd(client func() *ctlClient) *cobra.Command {
var (
endpoint string
model string
key string
timeout time.Duration
)
@@ -57,10 +58,21 @@ func newToolCheckCmd() *cobra.Command {
if endpoint == "" {
return fmt.Errorf("--endpoint is required, e.g. http://10.0.0.145:20002")
}
// A replica's engine demands its own key, and refused the check
// with a 401 when none was sent. Asked of the admin API, as bench
// does; an endpoint that is no replica's is asked without one.
if key == "" {
reps, err := listReplicas(cmd, client)
if err != nil {
cmd.PrintErrf("The admin API could not be asked for the engine key of the replica at "+
"--endpoint (%v); an engine that demands one will refuse. Pass --key to give it.\n", err)
}
key = replicaKey(reps, endpoint)
}
ctx, cancel := context.WithTimeout(cmd.Context(), timeout)
defer cancel()
res, err := probeToolCall(ctx, endpoint, model)
res, err := probeToolCall(ctx, endpoint, model, key)
if err != nil {
return err
}
@@ -75,6 +87,7 @@ func newToolCheckCmd() *cobra.Command {
}
cmd.Flags().StringVar(&endpoint, "endpoint", "", "engine base URL, e.g. http://10.0.0.145:20002")
cmd.Flags().StringVar(&model, "model", "", "model name to send, when the engine needs one")
cmd.Flags().StringVar(&key, "key", "", "the engine's key (default: the admin API's, for the replica at --endpoint)")
cmd.Flags().DurationVar(&timeout, "timeout", 5*time.Minute, "how long to wait; CPU inference is slow")
return cmd
}
@@ -102,7 +115,7 @@ type ToolProbe struct {
Raw string
}
func probeToolCall(ctx context.Context, endpoint, model string) (ToolProbe, error) {
func probeToolCall(ctx context.Context, endpoint, model, key string) (ToolProbe, error) {
// A question with one obvious tool answer and no other sensible reply, so a
// model that can use tools has no excuse not to.
body := map[string]any{
@@ -138,6 +151,9 @@ func probeToolCall(ctx context.Context, endpoint, model string) (ToolProbe, erro
return ToolProbe{}, err
}
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -150,6 +166,10 @@ func probeToolCall(ctx context.Context, endpoint, model string) (ToolProbe, erro
return ToolProbe{}, err
}
if resp.StatusCode != http.StatusOK {
if resp.StatusCode == http.StatusUnauthorized {
return ToolProbe{}, fmt.Errorf("%s answered 401: the engine's key is the admin API's, "+
"for the replica `llmgw ctl replicas` lists at --endpoint; or pass it with --key", url)
}
return ToolProbe{}, fmt.Errorf("%s returned %d: %s", url, resp.StatusCode, trim(string(raw), 300))
}
return readToolProbe(raw)
+55
View File
@@ -1,10 +1,65 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// toolCheck runs the command against an engine that answers only its own key,
// with an admin API that lists the replica there with that key.
func toolCheck(t *testing.T, args ...string) (string, error) {
t.Helper()
engine := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer engine-key" {
http.Error(w, `{"error":{"message":"Invalid API Key"}}`, http.StatusUnauthorized)
return
}
fmt.Fprint(w, `{"choices":[{"message":{"tool_calls":[{"id":"c1","type":"function",
"function":{"name":"get_weather","arguments":"{\"city\":\"Istanbul\"}"}}]}}]}`)
}))
admin := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]adminapi.ReplicaView{
{ID: "r1", Model: "m", Profile: "gpu", Endpoint: engine.URL, EngineKey: "engine-key"},
})
}))
t.Cleanup(func() { engine.Close(); admin.Close() })
t.Setenv("LLMGW_ADMIN_TOKEN", "t")
cmd := newToolCheckCmd(func() *ctlClient { return newCtlClient(admin.URL) })
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(append([]string{"--endpoint", engine.URL}, args...))
err := cmd.ExecuteContext(context.Background())
return out.String(), err
}
func TestToolCheckAsksWithTheEngineKeyTheAdminAPIGives(t *testing.T) {
// Every replica's engine demands its key, and the check was sent without
// one: it failed with a 401 on every engine in the cluster.
out, err := toolCheck(t)
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
if !strings.Contains(out, "tool calls work") {
t.Errorf("unexpected report:\n%s", out)
}
}
func TestToolCheckSaysWhereTheEngineKeyComesFrom(t *testing.T) {
out, err := toolCheck(t, "--key", "wrong")
if err == nil || !strings.Contains(err.Error(), "401") || !strings.Contains(err.Error(), "--key") {
t.Fatalf("expected the refusal explained, got %v\n%s", err, out)
}
}
func TestAStructuredToolCallIsRecognised(t *testing.T) {
p, err := readToolProbe([]byte(`{"choices":[{"message":{"role":"assistant",
"tool_calls":[{"id":"c1","type":"function","function":{"name":"get_weather","arguments":"{\"city\":\"Istanbul\"}"}}]}}]}`))
+2
View File
@@ -679,6 +679,8 @@ func (m *consoleModel) doEntry() tea.Cmd {
}}
case cBackup:
return m.runAfter("server", "backup", "--config="+m.file)
case cJudge:
return m.runAfter("ctl", server, "guard", "judge", "enable", "--replace")
}
return nil
}
+169 -35
View File
@@ -4,6 +4,7 @@ import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
@@ -44,6 +45,7 @@ const (
cUsage
cQuotas
cGuard
cKB
cFederation
cMirror
cJobs
@@ -58,16 +60,18 @@ const (
cAddControl
cReplicate
cServe
cJudge
cUpgrade
cRestartAll
cBackup
cWipe
cLeave
cCount
)
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Guardrails", "Federation", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Audit", "Manual",
"server.yaml", "Add a node", "Add a control plane", "Replicate the store", "Serve a model", "Upgrade the cluster",
"Restart all replicas", "Back up now", "Leave"}
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Guardrails", "Knowledge", "Federation", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Audit", "Manual",
"server.yaml", "Add a node", "Add a control plane", "Replicate the store", "Serve a model", "Serve the guardrails' judge", "Upgrade the cluster",
"Restart all replicas", "Back up now", "Wipe this machine", "Leave"}
// consoleAction is what is done once the screen closes: each runs its own
// screen, and the console comes back after.
@@ -138,15 +142,18 @@ type consoleModel struct {
node bool
// The settings.
file string
doc *settings.Doc
cfg any // the file as loaded, for the values that apply
levels []settingsLevel
docErr error
saveNote string
file string
doc *settings.Doc
cfg any // the file as loaded, for the values that apply
// settingsBase is the version of the cluster's settings the screen
// read, when the cluster keeps them (doc is then layered over them).
settingsBase int64
levels []settingsLevel
docErr error
saveNote string
// A question open as a dialog.
dialog string // "", "target", "edit", "addname", "saved", "confirm-leave", "error"
dialog string // "", "target", "edit", "addname", "wipe", "saved", "note", "confirm-leave", "error"
dialogTitle string
dialogText string
prompt textField
@@ -171,6 +178,7 @@ type consoleModel struct {
quotas []adminapi.QuotaView
quotasErr error
guard guardState
kb kbState
st *storeHolder
people peopleData
@@ -219,6 +227,26 @@ func (m *consoleModel) loadSettings() {
} else if !m.node {
m.cfg = config.Default()
}
// The cluster's keys from the cluster's settings, when the control
// plane answers with them; its own keys from its file.
if !m.node && m.client != nil {
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
var v adminapi.SettingsView
if err := m.client.get(ctx, "/api/v1/settings", &v); err == nil && v.Version > 0 {
if cdoc, err := settings.FromBytes("the cluster's settings", []byte(v.Body)); err == nil {
m.doc.Layer(cdoc, config.IsLocal)
m.settingsBase = v.Version
if file, err := config.ReadFile(m.file); err == nil {
if cluster, err := config.Parse([]byte(v.Body)); err == nil {
if cfg, err := config.FromMap(config.Layer(file, cluster)); err == nil {
m.cfg = cfg
}
}
}
}
}
}
if root, err := settings.Root(m.file); err == nil && len(m.levels) == 0 {
m.levels = []settingsLevel{{title: filepathBase(m.file), block: root, kind: settings.Block}}
}
@@ -297,6 +325,9 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.menu == cGuard {
cmds = append(cmds, m.fetchGuard())
}
if m.menu == cKB {
cmds = append(cmds, m.fetchKB())
}
if m.menu == cMirror {
cmds = append(cmds, m.fetchMirror())
}
@@ -355,6 +386,8 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.people = peopleData(msg)
case consoleQuotasMsg:
m.quotas, m.quotasErr = msg.rows, msg.err
case consoleKBMsg:
m.kb = kbState{view: msg.view, err: msg.err, loaded: true}
case consoleGuardMsg:
window := m.guard.window
m.guard = guardState{view: msg.view, events: msg.events, err: msg.err, window: window}
@@ -383,7 +416,7 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m consoleModel) entryOK(i int) bool {
if m.node {
return i == cSettings || i == cManual || i == cLeave
return i == cSettings || i == cManual || i == cWipe || i == cLeave
}
return i != cReplicate || !m.storeReplicated()
}
@@ -469,7 +502,9 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.ask("Replicate the store", "user@address of database machine", 2)
case cLeave:
return m.leave()
case cUpgrade, cRestartAll, cBackup:
case cWipe:
return m.askWipe()
case cUpgrade, cRestartAll, cBackup, cJudge:
return m, m.doEntry()
case cOverview:
return m, nil
@@ -511,6 +546,8 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.menuD = m.quotaMenu(m.quotas[m.row])
case cGuard:
m.menuD = m.guardAttachmentMenu(m.guard.view.Attachments[m.row])
case cKB:
m.menuD = m.kbMenu(m.kb.view.Collections[m.row])
case cMirror:
m.menuD = m.mirrorMenu(m.mirrorGroups()[m.row])
case cJobs:
@@ -568,6 +605,10 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.quotaForm(adminapi.QuotaView{})
case cGuard:
m.guardAttachForm()
case cKB:
if m.kb.view.Available {
m.kbCreateForm()
}
case cModels:
m.addModelForm()
case cMirror:
@@ -623,6 +664,8 @@ func (m consoleModel) onEnter() tea.Cmd {
return m.fetchQuotas()
case cGuard:
return m.fetchGuard()
case cKB:
return m.fetchKB()
case cMirror:
return m.fetchMirror()
case cMonitor:
@@ -643,10 +686,10 @@ func (m consoleModel) peopleView() bool {
}
func (m consoleModel) leave() (tea.Model, tea.Cmd) {
if m.doc != nil && m.doc.Changed > 0 {
if m.doc != nil && m.doc.Pending() > 0 {
m.dialog, m.dialogTitle = "confirm-leave", "Unsaved changes"
m.dialogText = fmt.Sprintf("%d change(s) to %s are not saved.\nLeave without saving them?\n\n%s",
m.doc.Changed, m.file, sDim.Render("Y leave · N stay"))
m.dialogText = fmt.Sprintf("%d change(s) are not saved.\nLeave without saving them?\n\n%s",
m.doc.Pending(), sDim.Render("Y leave · N stay"))
return m, nil
}
return m, tea.Quit
@@ -663,7 +706,7 @@ func (m consoleModel) ask(title, question string, n int) (tea.Model, tea.Cmd) {
func (m consoleModel) dialogKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
switch m.dialog {
case "error":
case "error", "note":
m.dialog = ""
return m, nil
case "confirm-leave":
@@ -720,6 +763,13 @@ func (m consoleModel) dialogKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.dialogText = floatingQuestion
m.prompt.Placeholder = "10.0.0.161/24"
}
case "wipe":
if host, _ := os.Hostname(); v != host {
m.dialogText = m.wipeQuestion() + "\n\n" + sBad.Render("that is not this machine's name; nothing is removed")
return m, nil
}
m.action = consoleAction{kind: "wipe"}
return m, tea.Quit
case "edit":
if err := m.doc.Set(m.editPath, m.editKind, m.prompt.Value()); err != nil {
m.dialogText = m.editQuestion() + "\n\n" + sBad.Render(err.Error())
@@ -917,28 +967,60 @@ func (m consoleModel) settingsKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
}
func (m consoleModel) save() (tea.Model, tea.Cmd) {
if m.doc.Changed == 0 {
if m.doc.Pending() == 0 {
m.dialog, m.dialogTitle, m.dialogText = "error", "Nothing to save", "No change has been made."
return m, nil
}
backup, err := m.doc.Save(func(p string) error { _, err := m.load(p); return err })
if err != nil {
notSaved := func(err error) (tea.Model, tea.Cmd) {
m.dialog, m.dialogTitle = "error", "Not saved"
m.dialogText = "The configuration would not load:\n\n" + sBad.Render(err.Error()) + "\n\n" + sDim.Render("Nothing was written. Any key to go back.")
m.dialogText = "The configuration would not load:\n\n" + sBad.Render(err.Error()) + "\n\n" +
sDim.Render("Nothing was written. Any key to go back.")
return m, nil
}
var said []string
// The cluster's settings first: checked by the control plane against
// its own file, and in force everywhere once kept.
if c := m.doc.Cluster(); c != nil && c.Changed > 0 {
body, err := c.Bytes()
if err != nil {
return notSaved(err)
}
req, _ := json.Marshal(adminapi.SettingsRequest{Base: m.settingsBase, Body: string(body), Note: "console"})
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var v adminapi.SettingsView
if err := m.client.put(ctx, "/api/v1/settings", req, &v); err != nil {
return notSaved(err)
}
c.Changed = 0
said = append(said, fmt.Sprintf("The cluster's settings are version %d: in force on every control plane "+
"within seconds.\nllmgw ctl config history lists the versions; rollback goes back to one.", v.Version))
}
fileSaved := m.doc.Changed > 0
if fileSaved {
backup, err := m.doc.Save(func(p string) error { _, err := m.load(p); return err })
if err != nil {
return notSaved(err)
}
line := "Saved " + m.file + "."
if backup != "" {
line += " The old file is " + backup + "."
}
said = append(said, line)
}
m.loadSettings()
note := ""
if backup != "" {
note = "The old file is " + backup + ".\n"
if !m.node {
if fileSaved {
said = append(said, "llmgw-server notices the change and applies it within seconds.")
}
m.dialog, m.dialogTitle = "note", "Saved"
m.dialogText = strings.Join(said, "\n\n") + "\n\n" + sDim.Render("Any key to go back.")
return m, nil
}
m.dialog, m.dialogTitle = "saved", "Saved"
m.dialogText = "Saved " + m.file + ".\n" + note +
fmt.Sprintf("\n%s reads it when it starts. Restart it now?\n", m.service())
if !m.node {
m.dialogText += sDim.Render("Another control plane keeps its own copy: make the change there too.") + "\n"
}
m.dialogText += "\n" + sDim.Render("Y restart · N later")
m.dialogText = strings.Join(said, "\n") + "\n" +
fmt.Sprintf("\n%s reads it when it starts. Restart it now?\n", m.service()) +
"\n" + sDim.Render("Y restart · N later")
return m, nil
}
@@ -960,6 +1042,8 @@ func (m consoleModel) rowCount() int {
return len(m.quotas)
case cGuard:
return len(m.guard.view.Attachments)
case cKB:
return len(m.kb.view.Collections)
case cMirror:
return len(m.mirrorGroups())
case cJobs:
@@ -999,7 +1083,7 @@ func (m consoleModel) View() string {
nav = append(nav, navItem{Label: "", Heading: true}, navItem{Label: "Help", Heading: true})
case cSettings:
nav = append(nav, navItem{Label: "", Heading: true}, navItem{Label: "Settings", Heading: true})
case cAddNode, cLeave:
case cAddNode, cWipe:
if (i == cAddNode) != m.node {
nav = append(nav, navItem{Label: "", Heading: true}, navItem{Label: "Do", Heading: true})
}
@@ -1011,8 +1095,8 @@ func (m consoleModel) View() string {
if n := runningJobs(m.data.jobs); i == cJobs && n > 0 {
label += fmt.Sprintf(" (%d)", n)
}
if i == cSettings && m.doc != nil && m.doc.Changed > 0 {
label += fmt.Sprintf(" (%d)", m.doc.Changed)
if i == cSettings && m.doc != nil && m.doc.Pending() > 0 {
label += fmt.Sprintf(" (%d)", m.doc.Pending())
}
nav = append(nav, navItem{Label: label, Current: i == m.menu, Dim: !m.entryOK(i)})
}
@@ -1040,7 +1124,7 @@ func (m consoleModel) View() string {
f.DialogTitle = m.dialogTitle
f.Dialog = m.dialogText
switch m.dialog {
case "target", "edit", "addname":
case "target", "edit", "addname", "wipe":
q := m.dialogText
if m.dialog == "target" && m.editKind > 1 {
q = fmt.Sprintf("%s %d of %d", q, len(m.asked)+1, int(m.editKind))
@@ -1163,13 +1247,17 @@ func (m consoleModel) panel() (title, body, keys string) {
for _, s := range r.Shards {
nodes = append(nodes, fmt.Sprintf("%s%v", s.Node, discovery.DeviceLabels(s.GPUs)))
}
rows = append(rows, []string{r.ID, r.Model, r.State, r.Pool, strings.Join(nodes, " "),
compute := "-"
if len(r.Shards) > 0 {
compute = r.Shards[0].Compute.Short()
}
rows = append(rows, []string{r.ID, r.Model, r.State, r.Pool, compute, strings.Join(nodes, " "),
(time.Duration(r.AgeSec) * time.Second).Round(time.Second).String()})
}
if m.inPanel {
keys = rowKeys
}
return "Replicas", "\n" + table([]string{"REPLICA", "MODEL", "STATE", "POOL", "ON", "AGE"}, rows, m.row, pw, m.inPanel), keys
return "Replicas", "\n" + table([]string{"REPLICA", "MODEL", "STATE", "POOL", "COMPUTE", "ON", "AGE"}, rows, m.row, pw, m.inPanel), keys
case cUsage:
head := "\n Last 24 hours, by " + lipgloss.NewStyle().Bold(true).Render(usageGroupings[m.usageBy]) +
sDim.Render(" (→ to group by "+usageGroupings[(m.usageBy+1)%len(usageGroupings)]+")") + "\n\n"
@@ -1189,6 +1277,8 @@ func (m consoleModel) panel() (title, body, keys string) {
return m.quotasPanel(pw)
case cGuard:
return m.guardPanel(pw)
case cKB:
return m.kbPanel(pw)
case cAccounts, cKeys, cOrgs:
return m.peoplePanel(pw)
case cManual:
@@ -1233,6 +1323,9 @@ func (m consoleModel) panel() (title, body, keys string) {
item = "\n Moves the store onto two machines under Patroni, replicated,\n so losing one loses nothing.\n\n " +
sDim.Render("On the command line:") +
"\n sudo --preserve-env=SSH_AUTH_SOCK llmgw server init \\\n --patroni <user>@<a> --patroni <user>@<b> --no-mirror --no-node --no-model"
case cJudge:
item = "\n Serves the model the guardrail policies ask as their judge — Llama\n Guard 3 1B on CPUs, under the name the policies give it — so what they\n judge is not refused (guard_unavailable). A model of that name that is\n not a judge is replaced. Overview says when a judge is missing.\n\n " +
sDim.Render("On the command line:") + "\n llmgw ctl guard judge status\n llmgw ctl guard judge enable --replace"
case cUpgrade:
item = "\n Moves the cluster to a release: this machine first, then each node\n in turn. Engines keep serving; a node that does not come back stops it.\n\n " +
sDim.Render("On the command line:") + "\n sudo llmgw ctl upgrade VERSION"
@@ -1242,6 +1335,9 @@ func (m consoleModel) panel() (title, body, keys string) {
case cBackup:
item = "\n Writes the store and /etc/llmgw to one archive in " + DefaultBackupDir + ",\n and keeps the last few. Copy it off this machine.\n\n " +
sDim.Render("On the command line:") + "\n sudo llmgw server backup"
case cWipe:
item = "\n Removes llmgw from this machine: its services, firewall table,\n configuration, certificates, the authority, secrets, state and users,\n so it can be set up again from nothing. Model weights, engines, the\n store's database and backups stay. Asks for the machine's name first.\n\n " +
sDim.Render("On the command line, with --models, --store or --all for more:") + "\n sudo llmgw wipe"
case cLeave:
item = "\n Closes the console. The cluster keeps running."
}
@@ -1565,6 +1661,16 @@ func runConsole(ctx context.Context, out io.Writer, file string) error {
// Already set up: only the store changes.
in.noMirror, in.noNode, in.noModel = true, true, true
err = in.runInitTUI(ctx, out)
case "wipe":
c := Root()
c.SetArgs([]string{"wipe", "--yes"})
c.SetOut(out)
c.SetErr(out)
if err := c.ExecuteContext(ctx); err != nil {
fmt.Fprintln(out, sBad.Render("✗ ")+err.Error())
}
// Nothing is left here for the console to show.
return nil
case "run":
c := Root()
c.SetArgs(act.targets)
@@ -1610,3 +1716,31 @@ func (m consoleModel) standbyNote() string {
}
return "\n " + sDim.Render("Standing by. Shown, and changed, on the leader: "+leader) + "\n"
}
// askWipe asks for this machine's name before removing llmgw from it, saying
// what would go.
func (m consoleModel) askWipe() (tea.Model, tea.Cmd) {
if os.Geteuid() != 0 {
m.dialog, m.dialogTitle = "error", "Wipe this machine"
m.dialogText = "Removing services, users and files under /etc needs root:\nstart the console with sudo llmgw."
return m, nil
}
m.dialog, m.dialogTitle, m.dialogText = "wipe", "Wipe this machine", m.wipeQuestion()
m.prompt = newTextField()
m.prompt.Width = 40
m.prompt.Focus()
return m, nil
}
func (m consoleModel) wipeQuestion() string {
host, _ := os.Hostname()
w := &wiper{}
var b strings.Builder
b.WriteString("This removes, on " + host + ":\n\n")
for _, s := range w.plan() {
b.WriteString(" - " + s.what + "\n")
}
b.WriteString("\nModel weights, engines, the store's database and backups stay\n" +
"(sudo llmgw wipe --all takes them too).\n\nType the machine's name to remove it all:")
return b.String()
}
+1 -2
View File
@@ -13,7 +13,6 @@ import (
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
@@ -57,7 +56,7 @@ var hfClients sync.Map
func (m consoleModel) registry() *hf.Client {
endpoint := ""
if cfg, err := config.Load(m.file); err == nil {
if cfg, err := loadEffectiveConfig(m.file); err == nil {
endpoint = cfg.Mirror.Registry
}
c, _ := hfClients.LoadOrStore(endpoint, hf.New(endpoint, 20*time.Second))
+242
View File
@@ -0,0 +1,242 @@
package cli
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// Knowledge bases, as the console shows them: each collection with its
// owner, model, documents and grants; Enter for what can be done to one, A
// to make one. People fill them from their own machines with llmgw-kb.
type kbState struct {
view adminapi.KBView
err error
// loaded is set once an answer came, so "none yet" is not shown while
// asking.
loaded bool
}
type consoleKBMsg struct {
view adminapi.KBView
err error
}
func (m consoleModel) fetchKB() tea.Cmd {
c := m.client
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var msg consoleKBMsg
msg.err = c.get(ctx, "/api/v1/kb", &msg.view)
return msg
}
}
func (m consoleModel) kbPanel(pw int) (string, string, string) {
keys := "→ or Enter open · ↑↓ choose · Q leave"
if m.inPanel {
keys = "↑↓ collection · Enter actions · A new collection · ← back · Q leave"
}
k := m.kb
var b strings.Builder
b.WriteString("\n " + sDim.Render("Documents requests are answered from; people add them with llmgw-kb.") + "\n\n")
switch {
case k.err != nil:
b.WriteString(" " + sBad.Render(k.err.Error()))
return "Knowledge", b.String(), keys
case !k.loaded:
b.WriteString(" " + sDim.Render("asking the control plane…"))
return "Knowledge", b.String(), keys
case !k.view.Available:
b.WriteString(" " + sBad.Render("Off: ") + k.view.Reason)
return "Knowledge", b.String(), keys
case len(k.view.Collections) == 0:
b.WriteString(" " + sDim.Render("No collections yet. A to make one."))
return "Knowledge", b.String(), keys
}
var rows [][]string
for _, c := range k.view.Collections {
var grants []string
for _, g := range c.Grants {
grants = append(grants, or(g.Name, g.Target)+" "+g.Perm)
}
rows = append(rows, []string{or(c.OrgName, c.Org) + "/" + c.Name, or(c.OwnerName, c.Owner),
fmt.Sprintf("%s (%d)", c.EmbedModel, c.Dims), fmt.Sprint(c.Documents), fmt.Sprint(c.Chunks), dash(strings.Join(grants, ", "))})
}
b.WriteString(table([]string{"COLLECTION", "OWNER", "MODEL", "DOCUMENTS", "CHUNKS", "GRANTED TO"}, rows, m.row, pw, m.inPanel))
if len(k.view.Defaults) > 0 {
b.WriteString("\n\n " + sTitle.Render("BY DEFAULT") + " " + sDim.Render("when a request names no collection") + "\n")
var drows [][]string
for _, d := range k.view.Defaults {
drows = append(drows, []string{or(d.Name, d.Target), strings.Join(d.Collections, ", ")})
}
b.WriteString(table([]string{"FOR", "ANSWERED FROM"}, drows, -1, pw, false))
}
b.WriteString("\n\n " + sDim.Render("On a person's machine: llmgw-kb sync COLLECTION FOLDER"))
return "Knowledge", b.String(), keys
}
// kbRef is how the admin API is told which collection: by its ID.
func kbRef(c adminapi.KBCollectionView) string { return url.PathEscape(c.ID) }
// kbCreateForm makes a collection.
func (m *consoleModel) kbCreateForm() {
model := ""
for _, md := range m.data.models {
n := strings.ToLower(md.Name)
if strings.Contains(n, "embed") || strings.Contains(n, "bge") || strings.Contains(n, "e5") {
model = md.Name
break
}
}
m.openForm(&formDialog{title: "New knowledge-base collection",
note: "Owned by part of the tree: it and everything below it may read and add to it.\n" +
"The model must embed (capabilities.embeddings) and be able to serve now.",
fields: []formField{
choiceF("owner", []string{"project", "team", "org", "user"}, "team"),
textF("which", "", "ORG/TEAM/PROJECT, ORG/TEAM, ORG, or the ID"),
textF("org", "", "for a person's collection only"),
textF("name", "", "how people name it to llmgw-kb"),
textF("model", model, "the catalog model that embeds it"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
for i, what := range map[int]string{1: "which " + v[0], 3: "a name", 4: "a model"} {
if strings.TrimSpace(v[i]) == "" {
return nil, fmt.Errorf("%s?", what)
}
}
req := adminapi.KBCreateRequest{Owner: v[0] + ":" + v[1], Org: v[2], Name: v[3], Model: v[4]}
return m.call("New collection "+v[3], http.MethodPost, "/api/v1/kb/collections", req, func(raw json.RawMessage) string {
var c adminapi.KBCollectionView
_ = json.Unmarshal(raw, &c)
return fmt.Sprintf("Collection %s, embedded by %s in %d dimensions.\n\nPeople with access fill it with:\n llmgw-kb sync %s FOLDER",
c.Name, c.EmbedModel, c.Dims, c.Name)
}), nil
}})
}
// kbMenu is what can be done to one collection.
func (m consoleModel) kbMenu(c adminapi.KBCollectionView) *menuDialog {
name := or(c.OrgName, c.Org) + "/" + c.Name
items := []menuItem{
{"Documents", func(m *consoleModel) tea.Cmd { return m.kbDocuments(c) }},
{"Grant…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Grant " + name,
note: "It and everything below it may then read, or with write add to, this collection.\nNever outside " + or(c.OrgName, c.Org) + ".",
fields: []formField{
choiceF("to", []string{"project", "team", "org", "user", "key"}, "project"),
textF("which", "", "ORG/TEAM/PROJECT, ORG/TEAM, ORG, or the ID"),
choiceF("may", []string{"read", "write"}, "read"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if strings.TrimSpace(v[1]) == "" {
return nil, fmt.Errorf("which %s?", v[0])
}
req := adminapi.KBGrantRequest{Collection: c.ID, Target: v[0] + ":" + strings.TrimSpace(v[1]), Perm: v[2]}
return m.call("Grant "+c.Name, http.MethodPost, "/api/v1/kb/grants", req, nil), nil
}})
return nil
}},
}
for _, g := range c.Grants {
items = append(items, menuItem{"Revoke " + or(g.Name, g.Target) + " (" + g.Perm + ")…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Revoke a grant on " + name,
text: or(g.Name, g.Target) + " may then no longer " + g.Perm + " it, unless it owns it.",
yes: func(m *consoleModel) tea.Cmd {
return m.call("Revoke", http.MethodDelete, "/api/v1/kb/grants",
adminapi.KBGrantRequest{Collection: c.ID, Target: g.Target}, nil)
}}
return nil
}})
}
items = append(items,
menuItem{"Answer from it by default…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Answer from " + name + " by default",
note: "Chat requests from there that name no collection are answered from this one.\n" +
"Replaces that part's default; llmgw ctl kb default sets several.",
fields: []formField{
choiceF("for", []string{"project", "team", "org", "key"}, "team"),
textF("which", "", "ORG/TEAM/PROJECT, ORG/TEAM, ORG, or the ID"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if strings.TrimSpace(v[1]) == "" {
return nil, fmt.Errorf("which %s?", v[0])
}
req := adminapi.KBDefaultRequest{Target: v[0] + ":" + strings.TrimSpace(v[1]), Collections: []string{c.ID}}
return m.call("Default "+c.Name, http.MethodPost, "/api/v1/kb/defaults", req, nil), nil
}})
return nil
}},
menuItem{"Try a search…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Search " + name,
note: "As an administrator: no tenancy applies. Shows what a question would find.",
fields: []formField{textF("question", "", ""), textF("results", "5", "how many chunks")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if v[0] == "" {
return nil, fmt.Errorf("what question?")
}
k, _ := strconv.Atoi(v[1])
req := adminapi.KBSearchRequest{Collection: c.ID, Query: v[0], K: k}
return m.call("Search "+c.Name, http.MethodPost, "/api/v1/kb/search", req, func(raw json.RawMessage) string {
var hits []adminapi.KBHitView
_ = json.Unmarshal(raw, &hits)
if len(hits) == 0 {
return "Nothing found."
}
var b strings.Builder
for i, h := range hits {
text := h.Text
if r := []rune(text); len(r) > 400 {
text = string(r[:400]) + "…"
}
fmt.Fprintf(&b, "%d. %s #%d\n%s\n\n", i+1, h.Document, h.Ord, indent(strings.TrimSpace(text), " "))
}
return strings.TrimRight(b.String(), "\n")
}), nil
}})
return nil
}},
menuItem{"Delete…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Delete " + name,
text: fmt.Sprintf("Its %d documents and %d chunks, and every grant, go with it. People's own files are not touched.",
c.Documents, c.Chunks),
yes: func(m *consoleModel) tea.Cmd {
return m.call("Delete "+c.Name, http.MethodDelete, "/api/v1/kb/collections/"+kbRef(c), nil, nil)
}}
return nil
}},
)
return &menuDialog{title: name, items: items}
}
// kbDocuments shows a collection's documents in a dialog.
func (m *consoleModel) kbDocuments(c adminapi.KBCollectionView) tea.Cmd {
return m.call("Documents of "+c.Name, http.MethodGet, "/api/v1/kb/collections/"+kbRef(c)+"/documents", nil,
func(raw json.RawMessage) string {
var docs []adminapi.KBDocumentView
_ = json.Unmarshal(raw, &docs)
if len(docs) == 0 {
return "None yet. On a person's machine: llmgw-kb sync " + c.Name + " FOLDER"
}
var b strings.Builder
for i, d := range docs {
if i == 40 {
fmt.Fprintf(&b, "… and %d more (llmgw ctl kb docs %s)", len(docs)-40, c.ID)
break
}
fmt.Fprintf(&b, "%-48s %5d chunks %s %s\n", d.Name, d.Chunks, d.AddedAt.Local().Format("2006-01-02 15:04"), d.AddedBy)
}
return strings.TrimRight(b.String(), "\n")
})
}
+62
View File
@@ -0,0 +1,62 @@
package cli
import (
"encoding/json"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
func TestACollectionIsMadeAndGrantedFromTheConsole(t *testing.T) {
m, s := consoleWith(t, cKB)
m.data.models = append(m.data.models, adminapi.ModelView{Name: "bge-m3"})
m.kb = kbState{loaded: true, view: adminapi.KBView{Available: true, Collections: []adminapi.KBCollectionView{{
ID: "kb_aaaaaaaaaaaaaaaa", Name: "policies", Org: "o1", OrgName: "Acme", Owner: "team:t1", OwnerName: "Acme / hr",
EmbedModel: "bge-m3", Dims: 1024, Documents: 3, Chunks: 40,
Grants: []adminapi.KBGrantView{{Target: "project:p9", Name: "Acme/sales/crm", Perm: "read"}},
}}}}
var tm tea.Model = m
view := tm.View()
for _, want := range []string{"Knowledge", "Acme/policies", "Acme / hr", "bge-m3 (1024)", "Acme/s", "llmgw-kb sync COLLECTION FOLDER"} {
if !strings.Contains(view, want) {
t.Errorf("missing %q in:\n%s", want, view)
}
}
// A: the form, prefilled with the embedding model.
tm = press(tm, "a")
f := tm.(consoleModel).formD
if f == nil || f.fields[4].value() != "bge-m3" {
t.Fatalf("no form, or the model not offered: %+v", f)
}
f.fields[1].text.SetValue("Acme/hr")
f.fields[3].text.SetValue("handbook")
tm = pressRun(tm, "tab", "tab", "tab", "tab", "tab", "enter")
var req adminapi.KBCreateRequest
got := s.last()
if !strings.HasPrefix(got, "POST /api/v1/kb/collections ") ||
json.Unmarshal([]byte(strings.SplitN(got, " ", 3)[2]), &req) != nil ||
req.Owner != "team:Acme/hr" || req.Name != "handbook" || req.Model != "bge-m3" {
t.Fatalf("sent %q", got)
}
// Enter on a row: grant to a project for writing.
m.formD = nil
tm = m
tm = press(tm, "enter")
if md := tm.(consoleModel).menuD; md == nil || !strings.Contains(md.items[2].label, "Revoke Acme/sales/crm") {
t.Fatalf("menu %+v", md)
}
tm = press(tm, "down", "enter")
f = tm.(consoleModel).formD
f.fields[1].text.SetValue("Acme/sales/crm")
f.fields[2].opt = 1 // write
tm = pressRun(tm, "tab", "tab", "tab", "enter")
if got := s.last(); got != `POST /api/v1/kb/grants {"collection":"kb_aaaaaaaaaaaaaaaa","target":"project:Acme/sales/crm","perm":"write"}` {
t.Fatalf("sent %q", got)
}
_ = tm
}
+1 -2
View File
@@ -12,7 +12,6 @@ import (
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
@@ -118,7 +117,7 @@ func (m consoleModel) fetchPeople() tea.Cmd {
}
}
}
if cfg, err := config.Load(file); err == nil {
if cfg, err := loadEffectiveConfig(file); err == nil {
for name := range cfg.Guard.Policies {
d.guardPolicies = append(d.guardPolicies, name)
}
+81 -1
View File
@@ -1,6 +1,9 @@
package cli
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
@@ -164,7 +167,7 @@ func TestTheConsoleWatchesTheClusterAndEditsItsSettings(t *testing.T) {
cm = mm.(consoleModel)
cm.prompt.SetValue("5")
mm = press(cm, "enter", "s")
if d := mm.(consoleModel).dialog; d != "saved" {
if d := mm.(consoleModel).dialog; d != "note" {
t.Fatalf("after saving the dialog is %q: %s", d, mm.(consoleModel).dialogText)
}
b, _ := os.ReadFile(file)
@@ -244,3 +247,80 @@ func TestTheConsoleShowsTheGuardrails(t *testing.T) {
t.Errorf("window %q", guardWindows[c.guard.window])
}
}
func TestTheConsoleEditsTheClustersSettingsInTheStore(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "server.yaml")
if err := os.WriteFile(file, []byte("listen: \":8080\"\n"), 0o644); err != nil {
t.Fatal(err)
}
var put adminapi.SettingsRequest
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/api/v1/settings":
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 7, Body: "router:\n retries: 2\n"})
case r.Method == "PUT" && r.URL.Path == "/api/v1/settings":
_ = json.NewDecoder(r.Body).Decode(&put)
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 8, Body: put.Body})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
m := newConsoleModel(strings.TrimPrefix(srv.URL, "http://"), file)
m.width, m.height, m.loaded = 120, 40, true
m.menu, m.inPanel = cSettings, true
m.loadSettings()
for i, r := range m.settingsRows() {
if r.label == "router" {
m.row = i
}
}
var mm tea.Model = press(m, "enter")
cm := mm.(consoleModel)
for i, r := range cm.settingsRows() {
if r.label == "retries" {
cm.row = i
}
}
if v := cm.View(); !strings.Contains(v, "2") {
t.Errorf("the cluster's value is not shown:\n%s", v)
}
mm = press(cm, "enter")
cm = mm.(consoleModel)
cm.prompt.SetValue("5")
mm = press(cm, "enter", "s")
if d := mm.(consoleModel).dialog; d != "note" || !strings.Contains(mm.(consoleModel).dialogText, "version 8") {
t.Fatalf("after saving: %q %s", d, mm.(consoleModel).dialogText)
}
if put.Base != 7 || !strings.Contains(put.Body, "retries: 5") {
t.Errorf("sent %+v", put)
}
if b, _ := os.ReadFile(file); string(b) != "listen: \":8080\"\n" {
t.Errorf("the cluster's key went into the machine's file:\n%s", b)
}
}
func TestTheConsoleWipesOnlyOnTheMachinesName(t *testing.T) {
m := newConsoleModel("127.0.0.1:1", filepath.Join(t.TempDir(), "server.yaml"))
m.width, m.height, m.loaded = 120, 40, true
m.menu = cWipe
m.dialog, m.dialogTitle, m.dialogText = "wipe", "Wipe this machine", m.wipeQuestion()
m.prompt = newTextField()
m.prompt.Focus()
if v := m.View(); !strings.Contains(v, "Type the machine's name") {
t.Errorf("the dialog does not ask for the name:\n%s", v)
}
m.prompt.SetValue("not-this-one")
mm := press(m, "enter")
if a := mm.(consoleModel).action; a.kind != "" {
t.Fatalf("a wrong name wiped: %+v", a)
}
cm := mm.(consoleModel)
host, _ := os.Hostname()
cm.prompt.SetValue(host)
mm = press(cm, "enter")
if a := mm.(consoleModel).action; a.kind != "wipe" {
t.Errorf("the right name did not: %+v", a)
}
}
+53 -4
View File
@@ -3,6 +3,7 @@ package cli
import (
"errors"
"fmt"
"net/url"
"strings"
"text/tabwriter"
"time"
@@ -34,7 +35,7 @@ func newUsersCmd() *cobra.Command {
"Stop an account working, keeping it and its history"),
newUserStateCmd(open, "reinstate", store.UserActive,
"Let a suspended account work again"),
newUserCreateCmd(open), newUserResetPasswordCmd(open),
newUserCreateCmd(open), newUserResetPasswordCmd(open), newUserResetLinkCmd(open, &sf),
newUserJoinCmd(open), newUserLeaveCmd(open), newUserDeleteCmd(open), newUserForgetCmd(open),
newUserAdminCmd(open, "promote", true, "Let an account see the cluster dashboard at /admin"),
newUserAdminCmd(open, "demote", false, "Take away access to the cluster dashboard"),
@@ -254,9 +255,10 @@ func newUserResetPasswordCmd(open userOpener) *cobra.Command {
cmd := &cobra.Command{
Use: "reset-password <user>",
Short: "Set a new password for someone who lost theirs",
Long: "There is no self-service reset, because this gateway sends no email and\n" +
"a reset link with nowhere to send it is not a reset. Set one here, tell\n" +
"them out of band, and have them change it from their profile.\n\n" +
Long: "People reset a forgotten password themselves at /forgot when the cluster\n" +
"has mail settings (mail.smtp); without them, 'llmgw users reset-link' makes\n" +
"a link to hand them. Or set one here, tell them out of band, and have them\n" +
"change it from their profile.\n\n" +
"Every session they have open is closed: if they lost the password, the\n" +
"reason might be that somebody else has it.",
Args: cobra.ExactArgs(1),
@@ -532,3 +534,50 @@ func newUserForgetCmd(open userOpener) *cobra.Command {
cmd.Flags().BoolVar(&yes, "yes", false, "confirm")
return cmd
}
func newUserResetLinkCmd(open userOpener, sf *storeFlags) *cobra.Command {
var base string
cmd := &cobra.Command{
Use: "reset-link <user>",
Short: "Make a one-time link for someone to set a forgotten password",
Long: "For a gateway that sends no mail, or a person whose mail does not arrive:\n" +
"the link is printed, to hand them however suits. It works once, for an\n" +
"hour, and a newer one replaces it. Setting the password closes every\n" +
"session they have open. The address it points at is mail.base_url from\n" +
"the cluster's settings, or --base-url.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if base == "" {
if cfg, err := loadEffectiveConfig(sf.config); err == nil {
base = cfg.Mail.BaseURL
}
}
if base == "" {
return errors.New("give --base-url, the address people reach this gateway at, or set mail.base_url " +
"(llmgw ctl config set mail.base_url https://…)")
}
s, err := open(cmd)
if err != nil {
return err
}
defer s.Close()
u, err := resolveUser(cmd.Context(), s, args[0])
if err != nil {
return err
}
if u.PasswordHash == "" {
return fmt.Errorf("%s signs in through single sign-on: there is no password to set", u.Email)
}
token, err := s.CreatePasswordReset(cmd.Context(), u.ID, account.ResetLinkTTL)
if err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s/reset?token=%s\n", strings.TrimRight(base, "/"), url.QueryEscape(token))
cmd.PrintErrf("For %s: works once, for %s. Hand it to them alone; it opens their account.\n",
u.Email, account.ResetLinkTTL)
return nil
},
}
cmd.Flags().StringVar(&base, "base-url", "", "the address people reach this gateway at (default: mail.base_url)")
return cmd
}
+277
View File
@@ -0,0 +1,277 @@
package cli
import (
"bufio"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"github.com/spf13/cobra"
)
// wipeUnits are the systemd units llmgw installs, stopped first so nothing
// writes while the rest goes.
var wipeUnits = []string{
"llmgw-server.service", "llmgw-agent.service", "llmgw-mirror.service",
firewallSyncPathUnit, "llmgw-firewall-sync.service", "llmgw-firewall.service",
"llmgw-backup.timer", "llmgw-backup.service",
}
// wiper removes llmgw from a machine. root prefixes every path, for tests;
// run runs a command.
type wiper struct {
root string
run func(name string, args ...string) error
store bool
models bool
all bool
}
// wipeStep is one thing to remove, said before it is done.
type wipeStep struct {
what string
do func() error
}
func (w *wiper) path(p string) string { return filepath.Join(w.root, p) }
func (w *wiper) exists(p string) bool {
_, err := os.Lstat(w.path(p))
return err == nil
}
func (w *wiper) glob(pattern string) []string {
m, _ := filepath.Glob(w.path(pattern))
out := make([]string, len(m))
for i, p := range m {
out[i] = strings.TrimPrefix(p, w.root)
}
return out
}
// mentions is whether a file is there and says llmgw: a file of a name
// another tool also uses (keepalived's, etcd's) is removed only when llmgw
// wrote it.
func (w *wiper) mentions(p string) bool {
b, err := os.ReadFile(w.path(p))
return err == nil && strings.Contains(string(b), "llmgw")
}
func (w *wiper) remove(paths ...string) func() error {
return func() error {
var errs []error
for _, p := range paths {
if err := os.RemoveAll(w.path(p)); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
}
// plan is every step, in order, for what is on this machine.
func (w *wiper) plan() []wipeStep {
var steps []wipeStep
add := func(what string, do func() error) { steps = append(steps, wipeStep{what, do}) }
files := func(what string, paths ...string) {
var there []string
for _, p := range paths {
if w.exists(p) {
there = append(there, p)
}
}
if len(there) > 0 {
add(what+": "+strings.Join(there, ", "), w.remove(there...))
}
}
// The services, stopped and gone.
var units []string
for _, u := range wipeUnits {
if w.exists("/etc/systemd/system/" + u) {
units = append(units, u)
}
}
if len(units) > 0 {
add("stop and remove "+strings.Join(units, ", "), func() error {
for _, u := range units {
_ = w.run("systemctl", "disable", "--now", u)
if err := os.Remove(w.path("/etc/systemd/system/" + u)); err != nil && !os.IsNotExist(err) {
return err
}
}
return w.run("systemctl", "daemon-reload")
})
}
// Engines run as their own user, kept alive across the agent's restarts:
// stopped by user, or they would keep their GPUs.
if w.userExists("llmgw-engine") {
add("stop the engines (every process of llmgw-engine)", func() error {
_ = w.run("pkill", "-KILL", "-u", "llmgw-engine")
return nil
})
}
add("take llmgw's table out of the firewall (nft inet llmgw), if loaded", func() error {
_ = w.run("nft", "delete", "table", "inet", "llmgw")
return nil
})
if w.mentions("/etc/keepalived/keepalived.conf") {
add("stop keepalived and remove llmgw's floating address (/etc/keepalived/keepalived.conf)", func() error {
_ = w.run("systemctl", "disable", "--now", "keepalived")
return w.remove("/etc/keepalived/keepalived.conf")()
})
}
files("configuration, certificates, the authority and secrets", "/etc/llmgw")
files("state: the admin token, a SQLite store, what the agent kept",
"/var/lib/llmgw", "/var/lib/llmgw-agent")
files("the sudo grant setup left", "/etc/sudoers.d/llmgw-setup")
if w.exists("/usr/local/share/ca-certificates/llmgw-ca.crt") {
add("the cluster's authority from the system's trust (llmgw-ca.crt)", func() error {
if err := w.remove("/usr/local/share/ca-certificates/llmgw-ca.crt")(); err != nil {
return err
}
_ = w.run("update-ca-certificates", "--fresh")
return nil
})
}
if w.exists("/etc/pki/ca-trust/source/anchors/llmgw-ca.pem") {
add("the cluster's authority from the system's trust (llmgw-ca.pem)", func() error {
if err := w.remove("/etc/pki/ca-trust/source/anchors/llmgw-ca.pem")(); err != nil {
return err
}
_ = w.run("update-ca-trust")
return nil
})
}
if w.store || w.all {
w.planStore(add, files)
}
if w.models || w.all {
files("model weights and the mirror", "/srv/models", "/var/lib/llmgw-weights", "/var/lib/llmgw-models")
files("installed engines", "/opt/llmgw")
}
if w.all {
files("backups", DefaultBackupDir)
}
for _, u := range []string{"llmgw-engine", "llmgw"} {
if w.userExists(u) {
add("the user "+u, func() error { return w.run("userdel", u) })
}
}
if w.all {
files("the llmgw binary", "/usr/local/bin/llmgw", "/usr/local/bin/llmgw.prev")
}
return steps
}
// planStore is the store, when it is on this machine: a member of the
// replicated one (Patroni, etcd), or llmgw's database in a local PostgreSQL.
func (w *wiper) planStore(add func(string, func() error), files func(string, ...string)) {
patroni := w.mentions("/etc/patroni/config.yml")
if patroni || w.mentions("/etc/default/etcd") {
add("stop Patroni and etcd", func() error {
_ = w.run("systemctl", "disable", "--now", "patroni")
_ = w.run("systemctl", "disable", "--now", "etcd")
return nil
})
}
if patroni {
data := append(w.glob("/var/lib/postgresql/*/llmgw"), w.glob("/var/lib/pgsql/*/llmgw")...)
files("the store's database on this member, and Patroni's configuration",
append([]string{"/etc/patroni", "/var/lib/postgresql/.pgpass_patroni", "/root/llmgw-pg"}, data...)...)
} else if w.exists("/usr/bin/psql") {
add("drop the llmgw database and role from the local PostgreSQL, if there", func() error {
_ = w.run("runuser", "-u", "postgres", "--", "psql", "-q",
"-c", "DROP DATABASE IF EXISTS llmgw", "-c", "DROP ROLE IF EXISTS llmgw")
return nil
})
}
if w.mentions("/etc/default/etcd") {
files("etcd's data and configuration", "/var/lib/etcd/default", "/etc/etcd/tls", "/etc/default/etcd")
}
}
func (w *wiper) userExists(name string) bool {
b, err := os.ReadFile(w.path("/etc/passwd"))
return err == nil && strings.Contains("\n"+string(b), "\n"+name+":")
}
func newWipeCmd() *cobra.Command {
var w wiper
var yes bool
c := &cobra.Command{
Use: "wipe",
Short: "Remove llmgw from this machine: its services, configuration and state",
Long: "Stops and removes llmgw's services, takes its firewall table out, and\n" +
"deletes its configuration, certificates, the authority, secrets, state\n" +
"and users — what server init, node add, server add and mirror init left —\n" +
"so the machine can be set up again from nothing. It says everything it\n" +
"will remove first, and asks for the machine's name.\n\n" +
"Kept unless asked: model weights, the mirror and engines (--models), the\n" +
"store's database when it is on this machine (--store), backups and the\n" +
"binary (--all, which is all of these). Packages llmgw installed —\n" +
"PostgreSQL, Patroni, etcd, nftables, the node exporter — stay.\n\n" +
"A node wiped is then forgotten on the control plane with\n" +
"'llmgw node remove <name>'.",
Example: " sudo llmgw wipe\n" +
" sudo llmgw wipe --models\n" +
" sudo llmgw wipe --all --yes",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if runtime.GOOS != "linux" {
return fmt.Errorf("wipe removes llmgw from a Linux machine; this is %s", runtime.GOOS)
}
if os.Geteuid() != 0 {
return errors.New("wipe removes services, users and files under /etc: run it with sudo")
}
out := cmd.OutOrStdout()
w.run = func(name string, args ...string) error {
return exec.CommandContext(cmd.Context(), name, args...).Run() //nolint:gosec // fixed commands
}
steps := w.plan()
host, _ := os.Hostname()
fmt.Fprintf(out, "On %s, this removes:\n", host)
for _, s := range steps {
fmt.Fprintf(out, " - %s\n", s.what)
}
if !yes {
if !isTerminal(out) {
return errors.New("nothing removed: give --yes to wipe without being asked")
}
fmt.Fprintf(out, "\nType this machine's name (%s) to remove all of it: ", host)
answer, _ := bufio.NewReader(cmd.InOrStdin()).ReadString('\n')
if strings.TrimSpace(answer) != host {
return errors.New("nothing removed")
}
}
var failed []string
for _, s := range steps {
if err := s.do(); err != nil {
failed = append(failed, s.what+": "+err.Error())
fmt.Fprintf(out, " !! %s: %v\n", s.what, err)
continue
}
fmt.Fprintf(out, " ok %s\n", s.what)
}
if len(failed) > 0 {
return fmt.Errorf("%d step(s) failed; running wipe again finishes them", len(failed))
}
fmt.Fprintln(out, "\nllmgw is gone from this machine. If it was a node, forget it on the control plane: "+
"llmgw node remove "+host)
return nil
},
}
f := c.Flags()
f.BoolVar(&yes, "yes", false, "do not ask")
f.BoolVar(&w.models, "models", false, "also remove model weights, the mirror's files and installed engines")
f.BoolVar(&w.store, "store", false, "also remove the store's database here: a Patroni member's data, etcd's, or llmgw's database in a local PostgreSQL")
f.BoolVar(&w.all, "all", false, "everything: --models, --store, backups and the binary")
return c
}
+106
View File
@@ -0,0 +1,106 @@
package cli
import (
"os"
"path/filepath"
"strings"
"testing"
)
func wipeFixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
for p, body := range map[string]string{
"/etc/llmgw/server.yaml": "listen: :8080\n",
"/etc/llmgw/pki/ca-key.pem": "key",
"/var/lib/llmgw/admin-token": "t",
"/var/lib/llmgw-agent/control-planes.json": "[]",
"/etc/systemd/system/llmgw-server.service": "[Unit]",
"/etc/systemd/system/llmgw-agent.service": "[Unit]",
"/etc/sudoers.d/llmgw-setup": "x",
"/etc/keepalived/keepalived.conf": "# llmgw: the gateway's floating address\n",
"/etc/passwd": "root:x:0:0::/root:/bin/sh\nllmgw:x:999:999::/var/lib/llmgw:/usr/sbin/nologin\nllmgw-engine:x:998:998::/:/usr/sbin/nologin\n",
"/srv/models/qwen/v1/q.gguf": "w",
"/opt/llmgw/engines/llamacpp/b1/llama-server": "e",
"/etc/patroni/config.yml": "scope: llmgw\n",
"/var/lib/postgresql/16/llmgw/PG_VERSION": "16",
"/etc/default/etcd": "ETCD_INITIAL_CLUSTER_TOKEN=llmgw\n",
"/var/lib/etcd/default/member/snap": "s",
"/usr/local/bin/llmgw": "bin",
} {
full := filepath.Join(root, p)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
}
return root
}
func TestWipeRemovesTheConfigurationAndKeepsWhatWasNotAsked(t *testing.T) {
root := wipeFixture(t)
var ran []string
w := &wiper{root: root, run: func(name string, args ...string) error {
ran = append(ran, name+" "+strings.Join(args, " "))
return nil
}}
for _, s := range w.plan() {
if err := s.do(); err != nil {
t.Fatalf("%s: %v", s.what, err)
}
}
for _, gone := range []string{"/etc/llmgw", "/var/lib/llmgw", "/var/lib/llmgw-agent",
"/etc/systemd/system/llmgw-server.service", "/etc/sudoers.d/llmgw-setup", "/etc/keepalived/keepalived.conf"} {
if _, err := os.Stat(filepath.Join(root, gone)); err == nil {
t.Errorf("%s is still there", gone)
}
}
for _, kept := range []string{"/srv/models/qwen/v1/q.gguf", "/opt/llmgw/engines", "/etc/patroni/config.yml",
"/var/lib/postgresql/16/llmgw", "/var/lib/etcd/default", "/usr/local/bin/llmgw"} {
if _, err := os.Stat(filepath.Join(root, kept)); err != nil {
t.Errorf("%s was removed without being asked", kept)
}
}
all := strings.Join(ran, "\n")
for _, want := range []string{"systemctl disable --now llmgw-server.service", "pkill -KILL -u llmgw-engine",
"nft delete table inet llmgw", "systemctl disable --now keepalived", "userdel llmgw-engine", "userdel llmgw"} {
if !strings.Contains(all, want) {
t.Errorf("did not run %q:\n%s", want, all)
}
}
if strings.Contains(all, "patroni") {
t.Error("the store was touched without --store")
}
}
func TestWipeAllTakesTheStoreAndTheModelsToo(t *testing.T) {
root := wipeFixture(t)
w := &wiper{root: root, all: true, run: func(string, ...string) error { return nil }}
for _, s := range w.plan() {
if err := s.do(); err != nil {
t.Fatalf("%s: %v", s.what, err)
}
}
for _, gone := range []string{"/srv/models", "/opt/llmgw", "/etc/patroni", "/var/lib/postgresql/16/llmgw",
"/var/lib/etcd/default", "/etc/default/etcd", "/usr/local/bin/llmgw"} {
if _, err := os.Stat(filepath.Join(root, gone)); err == nil {
t.Errorf("%s is still there", gone)
}
}
}
func TestWipeLeavesAKeepalivedItDidNotWrite(t *testing.T) {
root := t.TempDir()
p := filepath.Join(root, "/etc/keepalived/keepalived.conf")
_ = os.MkdirAll(filepath.Dir(p), 0o755)
_ = os.WriteFile(p, []byte("vrrp_instance site_vip {}\n"), 0o600)
w := &wiper{root: root, run: func(string, ...string) error { return nil }}
for _, s := range w.plan() {
_ = s.do()
}
if _, err := os.Stat(p); err != nil {
t.Error("someone else's keepalived configuration was removed")
}
}
+11 -1
View File
@@ -18,7 +18,12 @@ import (
//
// An empty path loads the defaults, which with --server and --node-id on the
// command line is enough for a smoke test on a node with no IB.
func LoadAgent(path string) (agent.Config, error) {
func LoadAgent(path string) (agent.Config, error) { return LoadAgentWith(path, nil) }
// LoadAgentWith is LoadAgent with the cluster's settings for this node
// (agents.default and agents.nodes.<id>, as YAML) laid over the file: the
// file is what the agent needs to start, the settings what it runs on.
func LoadAgentWith(path string, settings []byte) (agent.Config, error) {
cfg := agent.DefaultConfig()
k := koanf.New(".")
@@ -30,6 +35,11 @@ func LoadAgent(path string) (agent.Config, error) {
return cfg, fmt.Errorf("parsing %s: %w", path, err)
}
}
if len(settings) > 0 {
if err := k.Load(bytesProvider(settings), yaml.Parser()); err != nil {
return cfg, fmt.Errorf("parsing the cluster's settings for this agent: %w", err)
}
}
err := k.Load(env.Provider(".", env.Opt{
Prefix: "LLMGW_",
+110
View File
@@ -0,0 +1,110 @@
package config
import (
"fmt"
"slices"
"sort"
)
// AgentSettings are the nodes' agent settings, kept with the cluster's
// (BACKLOG E3): everything agent.yaml can say except what an agent needs to
// start — its id, the control planes to dial, its address and its
// certificate. Default applies to every node, and Nodes, by node id, over
// it; both over the node's own agent.yaml.
type AgentSettings struct {
// Default is agent.yaml's keys for every node.
Default map[string]any `koanf:"default"`
// Nodes are agent.yaml's keys for one node each, by node id, over
// Default.
Nodes map[string]map[string]any `koanf:"nodes"`
}
// agentBootstrapKeys are what an agent needs before it can be told anything:
// they stay in agent.yaml.
var agentBootstrapKeys = []string{"node_id", "server", "servers", "hostname", "mgmt_addr", "tls"}
// For is the settings for one node: Default with the node's own over it.
// Empty when there are none.
func (a AgentSettings) For(node string) map[string]any {
out := clone(stringKeys(a.Default))
if n, ok := a.Nodes[node]; ok {
merge(out, stringKeys(n))
}
return out
}
// Check refuses settings that set an agent's bootstrap keys, or that would
// not load as an agent's configuration for some node.
func (a AgentSettings) Check() error {
check := func(where string, m map[string]any) error {
for _, k := range agentBootstrapKeys {
if _, ok := m[k]; ok {
return fmt.Errorf("%s.%s stays in each node's agent.yaml: an agent needs it before it can be told anything", where, k)
}
}
return nil
}
if err := check("agents.default", stringKeys(a.Default)); err != nil {
return err
}
nodes := make([]string, 0, len(a.Nodes))
for n := range a.Nodes {
nodes = append(nodes, n)
}
sort.Strings(nodes)
for _, n := range nodes {
if err := check("agents.nodes."+n, stringKeys(a.Nodes[n])); err != nil {
return err
}
}
// Each node's settings as its agent would load them, over nothing: what
// its own file adds cannot be known here, and what is wrong in the
// settings alone is wrong on every node.
for _, n := range append([]string{""}, nodes...) {
b, err := Marshal(a.For(n))
if err != nil {
return err
}
if _, err := LoadAgentWith("", b); err != nil {
where := "agents.default"
if n != "" {
where = "agents.nodes." + n
}
return fmt.Errorf("%s: %w", where, err)
}
}
return nil
}
// AgentBootstrapKeys are the keys agent.yaml keeps, for saying so.
func AgentBootstrapKeys() []string { return slices.Clone(agentBootstrapKeys) }
// stringKeys turns YAML's map[any]any, wherever it hides, into the
// map[string]any the rest of this package merges.
func stringKeys(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
out[k] = normalize(v)
}
return out
}
func normalize(v any) any {
switch t := v.(type) {
case map[string]any:
return stringKeys(t)
case map[any]any:
m := make(map[string]any, len(t))
for k, vv := range t {
m[fmt.Sprint(k)] = normalize(vv)
}
return m
case []any:
out := make([]any, len(t))
for i, vv := range t {
out[i] = normalize(vv)
}
return out
}
return v
}
+79
View File
@@ -0,0 +1,79 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestANodesAgentSettingsAreTheDefaultWithItsOwnOver(t *testing.T) {
a := AgentSettings{
Default: map[string]any{"heartbeat_interval": "5s", "supervisor": map[string]any{"work_dir": "/srv/w", "x": 1}},
Nodes: map[string]map[string]any{
"cuda01": {"supervisor": map[string]any{"work_dir": "/fast/w"}, "labels": map[string]any{"gpu": "rtx6000"}},
},
}
got := a.For("cuda01")
sup := got["supervisor"].(map[string]any)
if sup["work_dir"] != "/fast/w" || sup["x"] != 1 || got["heartbeat_interval"] != "5s" || got["labels"] == nil {
t.Errorf("cuda01: %v", got)
}
if got := a.For("node01"); got["labels"] != nil || got["supervisor"].(map[string]any)["work_dir"] != "/srv/w" {
t.Errorf("node01: %v", got)
}
if got := (AgentSettings{}).For("x"); len(got) != 0 {
t.Errorf("none: %v", got)
}
}
func TestAgentSettingsKeepBootstrapKeysInTheFileAndMustLoad(t *testing.T) {
for _, a := range []AgentSettings{
{Default: map[string]any{"servers": []any{"x:7443"}}},
{Nodes: map[string]map[string]any{"n1": {"node_id": "other"}}},
{Nodes: map[string]map[string]any{"n1": {"tls": map[string]any{"ca": "/x"}}}},
} {
if err := a.Check(); err == nil || !strings.Contains(err.Error(), "stays in each node's agent.yaml") {
t.Errorf("%+v: %v", a, err)
}
}
// A fabric with no adapters would not load on the node it names.
bad := AgentSettings{Nodes: map[string]map[string]any{"n1": {"fabrics": []any{map[string]any{"name": "ib-a"}}}}}
if err := bad.Check(); err == nil || !strings.Contains(err.Error(), "agents.nodes.n1") || !strings.Contains(err.Error(), "no HCAs") {
t.Errorf("a fabric without HCAs: %v", err)
}
if err := (AgentSettings{Default: map[string]any{"heartbeat_interval": "3s"}}).Check(); err != nil {
t.Errorf("a plain setting: %v", err)
}
}
func TestTheClusterSettingsCarryAgentsAndRefuseBadOnes(t *testing.T) {
good, err := Parse([]byte("agents:\n default:\n heartbeat_interval: 3s\n nodes:\n cuda01:\n labels: {gpu: rtx6000}\n"))
if err != nil {
t.Fatal(err)
}
cfg, err := FromMap(good)
if err != nil {
t.Fatal(err)
}
if cfg.Agents.For("cuda01")["labels"] == nil {
t.Errorf("agents not read: %+v", cfg.Agents)
}
bad, _ := Parse([]byte("agents:\n default:\n mgmt_addr: 10.0.0.1\n"))
if _, err := FromMap(bad); err == nil {
t.Error("a bootstrap key in the cluster's settings was taken")
}
}
func TestTheSettingsAreLaidOverTheAgentsFile(t *testing.T) {
p := filepath.Join(t.TempDir(), "agent.yaml")
_ = os.WriteFile(p, []byte("node_id: n1\nserver: cp:7443\nheartbeat_interval: 9s\nlabels: {rack: r1}\n"), 0o600)
cfg, err := LoadAgentWith(p, []byte("heartbeat_interval: 2s\nlabels: {gpu: a100}\n"))
if err != nil {
t.Fatal(err)
}
if cfg.NodeID != "n1" || cfg.HeartbeatInterval != 2*time.Second || cfg.Labels["rack"] != "r1" || cfg.Labels["gpu"] != "a100" {
t.Errorf("loaded %+v", cfg)
}
}
+47 -7
View File
@@ -14,12 +14,12 @@ import (
"github.com/knadh/koanf/parsers/yaml"
env "github.com/knadh/koanf/providers/env/v2"
"github.com/knadh/koanf/providers/file"
"github.com/knadh/koanf/v2"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/mail"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
"gitea.mynodes.xyz/ilker/llmgw/internal/obs"
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
@@ -147,6 +147,24 @@ type Server struct {
// Prometheus and Alertmanager in between. Empty sends nothing.
Notify notify.Config `koanf:"notify"`
// Mail is where the gateway's few emails go out from: a link to set a
// forgotten password. Unset, none is sent and people ask an operator.
Mail mail.Config `koanf:"mail"`
// Serving stops and starts the whole cluster's service (llmgw stop, llmgw
// start).
Serving ServingSettings `koanf:"serving"`
// Firewall turns llmgw's firewall on every machine on or off.
Firewall FirewallSettings `koanf:"firewall"`
// Agents are the nodes' agent settings: agent.yaml's keys but those an
// agent needs to start, for every node (default) and for one
// (nodes.<id>). Sent to the agents as they change; an agent whose
// settings changed starts again on them, its engines serving
// throughout.
Agents AgentSettings `koanf:"agents"`
// FirewallNetworks are networks, besides those each machine reaches the
// cluster on, that llmgw's internal ports answer: a monitoring host on
// another subnet, nodes behind a router. Written into every machine's
@@ -764,6 +782,7 @@ func Default() Server {
AgentListen: ":7443",
MaxConnections: 10_000,
Mirror: Mirror{UpdateCheck: 6 * time.Hour},
Firewall: FirewallSettings{Enabled: true},
Control: Control{
Interval: 5 * time.Second,
BaseBackoff: 10 * time.Second,
@@ -823,15 +842,30 @@ func Default() Server {
// An empty path loads the defaults, which is what makes `llmgw server` with a
// couple of --endpoint flags usable for a smoke test.
func Load(path string) (Server, error) {
cfg := Default()
k := koanf.New(".")
var b []byte
if path != "" {
if _, err := os.Stat(path); err != nil {
return cfg, fmt.Errorf("config file: %w", err)
return Default(), fmt.Errorf("config file: %w", err)
}
if err := k.Load(file.Provider(path), yaml.Parser()); err != nil {
return cfg, fmt.Errorf("parsing %s: %w", path, err)
var err error
if b, err = os.ReadFile(path); err != nil { //nolint:gosec // the configuration
return Default(), fmt.Errorf("config file: %w", err)
}
}
cfg, err := load(b)
if err != nil && path != "" && strings.HasPrefix(err.Error(), "parsing settings") {
return cfg, fmt.Errorf("parsing %s:%s", path, strings.TrimPrefix(err.Error(), "parsing settings:"))
}
return cfg, err
}
// load reads a YAML document of settings over the defaults.
func load(b []byte) (Server, error) {
cfg := Default()
k := koanf.New(".")
if len(b) > 0 {
if err := k.Load(bytesProvider(b), yaml.Parser()); err != nil {
return cfg, fmt.Errorf("parsing settings: %w", err)
}
}
@@ -860,6 +894,9 @@ func Load(path string) (Server, error) {
// Validate rejects configurations that would otherwise fail confusingly at
// request time.
func (s Server) Validate() error {
if err := s.Agents.Check(); err != nil {
return err
}
for i, o := range s.EngineOverrides {
if o.GPUClass == "" && len(o.Nodes) == 0 && len(o.Labels) == 0 {
return fmt.Errorf("engine_overrides[%d] picks no nodes: give it a gpu_class, nodes or labels", i)
@@ -896,6 +933,9 @@ func (s Server) Validate() error {
return fmt.Errorf("backup_copy_to needs backup_recipients: an archive holds the certificate authority's key " +
"and every secret in the store, and leaves this machine only encrypted")
}
if err := s.Mail.Validate(); err != nil {
return err
}
if _, err := s.FirewallAllow(); err != nil {
return err
}
+12
View File
@@ -32,3 +32,15 @@ func (s Server) EnginePorts() (int, int) {
d := Default().Control.PortRange
return d[0], d[1]
}
// FirewallSettings is whether llmgw keeps a firewall on the cluster's
// machines.
type FirewallSettings struct {
// Enabled keeps llmgw's internal ports — engines, node exporter, the
// agents' stream, the mirror, the store — to the cluster's networks on
// every machine. False removes llmgw's nftables table everywhere, for a
// site whose own firewall does that job; the rules are kept, and true
// puts them back. Changed in the cluster's settings, it is in force on
// every node and control plane within seconds.
Enabled bool `koanf:"enabled"`
}
+256
View File
@@ -0,0 +1,256 @@
package config
import (
"bytes"
"errors"
"fmt"
"maps"
"os"
"reflect"
"slices"
"strings"
"go.yaml.in/yaml/v3"
)
// localKeys are the keys of server.yaml that belong to one machine: where it
// listens, its certificates, how it reaches the store, its own secrets and
// files. Everything else is the cluster's, kept in the store and the same on
// every control plane (see Layer).
var localKeys = [][]string{
{"listen"}, {"admin_listen"}, {"metrics_listen"}, {"agent_listen"},
{"max_connections"}, {"admin_token_file"}, {"admin_remote"}, {"debug_pprof"},
{"agent_insecure"}, {"agent_tls"},
{"auth", "store"}, {"threads", "store"},
{"secrets"}, {"backup_dir"}, {"backup_copy_key"},
{"tracing"}, {"monitoring"},
{"mirror", "api"}, {"mirror", "tls"},
{"endpoints"},
}
// IsLocal is whether a key path is one machine's own, kept in its
// server.yaml, rather than the cluster's.
func IsLocal(path []string) bool {
for _, l := range localKeys {
if len(path) >= len(l) && slices.Equal(path[:len(l)], l) {
return true
}
}
return false
}
// LocalKeys are the per-machine keys, dotted, for saying which they are.
func LocalKeys() []string {
out := make([]string, len(localKeys))
for i, l := range localKeys {
out[i] = strings.Join(l, ".")
}
return out
}
// ReadFile is a configuration file as YAML maps, empty for no path.
func ReadFile(path string) (map[string]any, error) {
if path == "" {
return map[string]any{}, nil
}
b, err := os.ReadFile(path) //nolint:gosec // the configuration
if err != nil {
return nil, fmt.Errorf("config file: %w", err)
}
m, err := Parse(b)
if err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
return m, nil
}
// Parse reads a YAML document of settings.
func Parse(b []byte) (map[string]any, error) {
m := map[string]any{}
if len(bytes.TrimSpace(b)) == 0 {
return m, nil
}
if err := yaml.Unmarshal(b, &m); err != nil {
return nil, err
}
if m == nil {
m = map[string]any{}
}
return m, nil
}
// Split separates settings into one machine's and the cluster's.
func Split(m map[string]any) (local, cluster map[string]any) {
local, cluster = map[string]any{}, map[string]any{}
split(m, nil, local, cluster)
return local, cluster
}
func split(m map[string]any, path []string, local, cluster map[string]any) {
for k, v := range m {
p := append(slices.Clone(path), k)
switch {
case IsLocal(p):
local[k] = v
case hasLocalBelow(p):
sub, ok := v.(map[string]any)
if !ok {
cluster[k] = v
continue
}
l, c := map[string]any{}, map[string]any{}
split(sub, p, l, c)
if len(l) > 0 {
local[k] = l
}
if len(c) > 0 {
cluster[k] = c
}
default:
cluster[k] = v
}
}
}
// hasLocalBelow is whether some local key lies under a path: auth holds
// store, which is local, beside its policies, which are not.
func hasLocalBelow(path []string) bool {
for _, l := range localKeys {
if len(l) > len(path) && slices.Equal(l[:len(path)], path) {
return true
}
}
return false
}
// Layer is what a control plane runs on: its own keys from its server.yaml
// and every other one from the cluster's settings. The file's cluster keys
// are not read once the cluster has settings of its own; Ignored names them.
func Layer(file, cluster map[string]any) map[string]any {
local, _ := Split(file)
out := clone(local)
merge(out, cluster)
return out
}
// Ignored are the cluster keys a file has that the cluster's settings, once
// there, take the place of.
func Ignored(file map[string]any) []string {
_, c := Split(file)
return flatKeys(c, "")
}
func flatKeys(m map[string]any, prefix string) []string {
var out []string
for _, k := range slices.Sorted(maps.Keys(m)) {
p := prefix + k
if sub, ok := m[k].(map[string]any); ok && hasLocalBelow(strings.Split(p, ".")) {
out = append(out, flatKeys(sub, p+".")...)
continue
}
out = append(out, p)
}
return out
}
// CheckCluster refuses a cluster document that sets one machine's keys: a
// listener or a certificate path written there would be every control
// plane's, and none of them could start on it.
func CheckCluster(m map[string]any) error {
local, _ := Split(m)
if keys := flatKeys(local, ""); len(keys) > 0 {
return fmt.Errorf("%s belong to each machine's server.yaml, not to the cluster's settings",
strings.Join(keys, ", "))
}
return nil
}
// Changed are the keys whose values differ between two sets of settings,
// at the top level, and below it where a key mixes local and cluster ones.
func Changed(a, b map[string]any) []string {
var out []string
changed(a, b, nil, &out)
slices.Sort(out)
return out
}
func changed(a, b map[string]any, path []string, out *[]string) {
keys := map[string]bool{}
for k := range a {
keys[k] = true
}
for k := range b {
keys[k] = true
}
for k := range keys {
p := append(slices.Clone(path), k)
va, vb := a[k], b[k]
if reflect.DeepEqual(va, vb) {
continue
}
ma, okA := va.(map[string]any)
mb, okB := vb.(map[string]any)
if hasLocalBelow(p) && (okA || va == nil) && (okB || vb == nil) {
changed(ma, mb, p, out)
continue
}
*out = append(*out, strings.Join(p, "."))
}
}
// Marshal writes settings as YAML.
func Marshal(m map[string]any) ([]byte, error) {
if len(m) == 0 {
return nil, nil
}
var b bytes.Buffer
enc := yaml.NewEncoder(&b)
enc.SetIndent(2)
if err := enc.Encode(m); err != nil {
return nil, err
}
return b.Bytes(), enc.Close()
}
// FromMap is the configuration settings make: laid over the defaults, with
// LLMGW_ environment overrides, and validated.
func FromMap(m map[string]any) (Server, error) {
b, err := Marshal(m)
if err != nil {
return Default(), err
}
return load(b)
}
// bytesProvider hands koanf a document already read.
type bytesProvider []byte
func (b bytesProvider) ReadBytes() ([]byte, error) { return b, nil }
func (b bytesProvider) Read() (map[string]any, error) {
return nil, errors.New("bytesProvider reads bytes only")
}
func clone(m map[string]any) map[string]any {
out := make(map[string]any, len(m))
for k, v := range m {
if sub, ok := v.(map[string]any); ok {
v = clone(sub)
}
out[k] = v
}
return out
}
// merge lays src over dst: maps key by key, everything else replaced.
func merge(dst, src map[string]any) {
for k, v := range src {
if sv, ok := v.(map[string]any); ok {
if dv, ok := dst[k].(map[string]any); ok {
merge(dv, sv)
continue
}
v = clone(sv)
}
dst[k] = v
}
}
+99
View File
@@ -0,0 +1,99 @@
package config
import (
"slices"
"strings"
"testing"
)
const machineFile = `
listen: ":8080"
agent_listen: ":7443"
agent_insecure: true
auth:
enabled: true
store: /var/lib/llmgw/llmgw.db
default_rpm: 60
mirror:
api: http://127.0.0.1:8082
update_check: 6h
guard:
default: [baseline]
policies:
baseline:
input:
injection: flag
router:
retries: 2
`
func TestAFileSplitsIntoTheMachinesAndTheClusters(t *testing.T) {
file, err := Parse([]byte(machineFile))
if err != nil {
t.Fatal(err)
}
local, cluster := Split(file)
for _, k := range []string{"listen", "agent_listen", "agent_insecure"} {
if _, ok := local[k]; !ok {
t.Errorf("%s is not the machine's", k)
}
}
if local["auth"].(map[string]any)["store"] == nil || cluster["auth"].(map[string]any)["store"] != nil {
t.Errorf("auth.store went to the cluster: local %v cluster %v", local["auth"], cluster["auth"])
}
if cluster["auth"].(map[string]any)["default_rpm"] != 60 {
t.Errorf("auth's policy stayed with the machine: %v", cluster["auth"])
}
if local["mirror"].(map[string]any)["api"] == nil || cluster["mirror"].(map[string]any)["update_check"] == nil {
t.Errorf("mirror split wrong: local %v cluster %v", local["mirror"], cluster["mirror"])
}
for _, k := range []string{"guard", "router"} {
if _, ok := cluster[k]; !ok {
t.Errorf("%s is not the cluster's", k)
}
}
if err := CheckCluster(cluster); err != nil {
t.Errorf("the cluster half is refused: %v", err)
}
if err := CheckCluster(file); err == nil || !strings.Contains(err.Error(), "auth.store") {
t.Errorf("a cluster document with a machine's keys: %v", err)
}
}
func TestTheClustersSettingsTakeThePlaceOfTheFiles(t *testing.T) {
file, _ := Parse([]byte(machineFile))
cluster, _ := Parse([]byte("router:\n retries: 5\nauth:\n enabled: true\n default_rpm: 10\n"))
eff := Layer(file, cluster)
cfg, err := FromMap(eff)
if err != nil {
t.Fatal(err)
}
if cfg.Router.Retries != 5 || cfg.Auth.DefaultRPM != 10 {
t.Errorf("cluster values not in force: retries %d rpm %d", cfg.Router.Retries, cfg.Auth.DefaultRPM)
}
if cfg.Auth.Store != "/var/lib/llmgw/llmgw.db" || cfg.Mirror.API != "http://127.0.0.1:8082" || cfg.Listen != ":8080" {
t.Errorf("the machine's own keys lost: %+v", cfg.Auth)
}
if len(cfg.Guard.Policies) != 0 {
t.Error("the file's guard applies though the cluster has settings without one")
}
if got := Ignored(file); !slices.Contains(got, "guard") || !slices.Contains(got, "auth.default_rpm") ||
slices.Contains(got, "auth.store") {
t.Errorf("ignored: %v", got)
}
}
func TestChangedNamesWhatMoved(t *testing.T) {
a, _ := Parse([]byte(machineFile))
b, _ := Parse([]byte(strings.Replace(machineFile, "retries: 2", "retries: 3", 1)))
if got := Changed(a, b); !slices.Equal(got, []string{"router"}) {
t.Errorf("changed %v", got)
}
c, _ := Parse([]byte(strings.Replace(machineFile, "store: /var/lib/llmgw/llmgw.db", "store: /x.db", 1)))
if got := Changed(a, c); !slices.Equal(got, []string{"auth.store"}) {
t.Errorf("changed %v", got)
}
if got := Changed(a, a); len(got) != 0 {
t.Errorf("unchanged: %v", got)
}
}
+11
View File
@@ -0,0 +1,11 @@
package config
// ServingSettings is whether the cluster serves at all.
type ServingSettings struct {
// Stopped takes every model down and answers every request for one with
// 503 (system_stopped), leaving the control planes, agents and mirror
// running and every model's definition as it is: llmgw stop sets it,
// llmgw start clears it, and the models come back as their catalog
// entries ask. In force on every control plane within seconds.
Stopped bool `koanf:"stopped"`
}
Loaded 100 of 240 files, more files were not shown because too many files have changed in this diff. Show more