Compare commits

..
24 Commits
Author SHA1 Message Date
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
113 changed files with 5796 additions and 557 deletions

No files matched your search

+72
View File
@@ -818,6 +818,78 @@ The same document, edited on /admin/settings, for whoever has no terminal.
---
## 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)
- `pgvector` ≥ 0.8 installed on every Patroni member by `server init`
(`internal/cli/serverinit_postgres.go`), `CREATE EXTENSION` in the
migration; the SQLite migration has no tables, and the store reports the
feature unavailable.
- Tables: `kb_collections` (id, org_id, owner `team:…`/`project:…`/`user:…`,
name, embed_model, dims, created_at), `kb_grants` (scope, scope_id,
collection, perm read|write — like `guard_attachments`), `kb_documents`
(name, digest, bytes, state, chunk count), `kb_chunks` (org_id,
collection, doc_id, ord, body, tsv, embedding) `PARTITION BY LIST
(org_id)`, a partition per organization, HNSW and GIN indexes per
partition.
- Row-level security on `kb_chunks` against `current_setting('llmgw.org')`
and `llmgw.collections`, retrieval through its own role.
- Rules, refused before anything is written: a grant never crosses
organizations; owner and grants resolve to existing scopes; deleting a
project/team/org and `users forget` delete their collections (`forget.go`).
- Done: store tests on PostgreSQL, including an RLS test that a query
without the settings returns no rows.
### F2. Ingest (M)
- `llmgw ctl kb create|list|delete|grant|revoke|docs`, `ctl kb add
<collection> <files…>`, admin API under `/api/v1/kb`, audit events.
- Text from PDF (text layer), DOCX, HTML, Markdown, plain text — in Go, no
Python. Split on headings/paragraphs, ~500 tokens with overlap. Embedded
through the router at the bronze band, accounted to the collection's
owner. One transaction per document; same digest skipped, changed digest
replaces. OCR out of scope.
- Console: a Knowledge screen (collections, documents, grants; add, grant,
revoke, delete); `docs/console.md`, `docs/knowledge.md`.
### F3. Retrieval on the request path (M)
- `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 (`hnsw.iterative_scan`,
`WHERE org_id = $1 AND collection = ANY($2)`), full-text arm merged by
reciprocal rank, `/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.
### 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`.
+128
View File
@@ -17,11 +17,139 @@ is only released once its **exit criterion** is met, not when its code lands.
| 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 |
| 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.
## 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
File diff suppressed because it is too large. Load diff
+28
View File
@@ -72,6 +72,7 @@ message ControlMessage {
UpgradeAgent upgrade = 10;
ControlPlanes control_planes = 11;
Firewall firewall = 13;
RestartAgent restart_agent = 14;
}
// 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
@@ -81,6 +82,12 @@ 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
@@ -266,6 +273,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 {
@@ -605,6 +617,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;
@@ -626,6 +650,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;
+224 -2
View File
@@ -17,6 +17,9 @@
}
],
"tags": [
{
"name": "agents"
},
{
"name": "audit"
},
@@ -100,6 +103,38 @@
}
],
"paths": {
"/api/v1/agents/restart": {
"post": {
"operationId": "postAgentsRestart",
"summary": "Have every node's agent start again; their engines keep serving",
"tags": [
"agents"
],
"parameters": [
{
"$ref": "#/components/parameters/Actor"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/AgentRestartView"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/audit": {
"get": {
"operationId": "getAudit",
@@ -924,6 +959,48 @@
}
}
},
"/api/v1/guard/judges/ask": {
"post": {
"operationId": "postGuardJudgesAsk",
"summary": "Have a judge read a text and say what it answered; nothing is kept",
"tags": [
"guard"
],
"parameters": [
{
"$ref": "#/components/parameters/Actor"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JudgeAskRequest"
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JudgeAskView"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/jobs": {
"get": {
"operationId": "getJobs",
@@ -3512,6 +3589,18 @@
}
}
},
"AgentRestartView": {
"type": "object",
"description": "AgentRestartView names the nodes whose agents were told to restart.",
"properties": {
"nodes": {
"type": "array",
"items": {
"type": "string"
}
}
}
},
"CanaryPercentRequest": {
"type": "object",
"description": "CanaryPercentRequest changes the share of a model's requests its canary takes.",
@@ -3672,6 +3761,29 @@
}
}
},
"ComputeView": {
"type": "object",
"description": "ComputeView is where an engine runs its model, by its own account.",
"properties": {
"backend": {
"type": "string",
"description": "Backend is CUDA, Vulkan, Metal, ROCm, GPU or CPU."
},
"devices": {
"type": "array",
"items": {
"type": "string"
}
},
"layers_offloaded": {
"type": "integer",
"description": "LayersOffloaded of LayersTotal went to the devices; absent when unsaid."
},
"layers_total": {
"type": "integer"
}
}
},
"ControlPlane": {
"type": "object",
"description": "ControlPlane is one control plane.",
@@ -4129,6 +4241,10 @@
"category": {
"type": "string"
},
"rule": {
"type": "string",
"description": "Rule is what decided it within the category: a pattern's name, or a judge's verdict, \"S6,Specialized Advice\"."
},
"guard_ms": {
"type": "integer",
"format": "int64"
@@ -4191,6 +4307,10 @@
"type": "object",
"description": "GuardView is the library and where its policies are attached.",
"properties": {
"enabled": {
"type": "boolean",
"description": "Enabled is whether the guardrails check anything (guard.enabled)."
},
"policies": {
"type": "array",
"items": {
@@ -4389,6 +4509,50 @@
}
}
},
"JudgeAskRequest": {
"type": "object",
"description": "JudgeAskRequest is a text to have a judge read.",
"properties": {
"model": {
"type": "string",
"description": "Model is the judge; empty is the one the policies ask, when they ask one."
},
"text": {
"type": "string"
},
"answer": {
"type": "string",
"description": "Answer, when given, is read as the assistant's reply to Text."
}
}
},
"JudgeAskView": {
"type": "object",
"description": "JudgeAskView is what the judge answered.",
"properties": {
"model": {
"type": "string"
},
"verdict": {
"type": "string",
"description": "Verdict is safe, unsafe or controversial, as the gateway reads the answer; a policy's judge.controversial says what controversial counts as."
},
"categories": {
"type": "array",
"items": {
"type": "string"
}
},
"raw": {
"type": "string",
"description": "Raw is the judge's answer as it gave it."
},
"took_ms": {
"type": "integer",
"format": "int64"
}
}
},
"JudgeView": {
"type": "object",
"description": "JudgeView is one judge model the guardrail policies ask, and whether it can answer them.",
@@ -4409,6 +4573,13 @@
"type": "string"
}
},
"then": {
"type": "array",
"items": {
"type": "string"
},
"description": "Then are the judges its policies ask after it, when it gives no verdict: a CPU judge behind a GPU one."
},
"in_catalog": {
"type": "boolean",
"description": "InCatalog is whether a model of that name is in the catalog, and Judge whether it is there as a judge (role: guard)."
@@ -4666,6 +4837,10 @@
"source": {
"type": "string"
},
"role": {
"type": "string",
"description": "Role is \"guard\" for a guardrails judge, empty for a model clients use."
},
"min_replicas": {
"type": "integer"
},
@@ -4914,6 +5089,23 @@
},
"description": "Engines are the inference engines installed here, as the agent found them. Absent means the agent is older than the probe and said nothing, which is not the same as having none — placement treats it that way too, or a control plane upgrade would empty a fleet."
},
"engine_versions": {
"type": "object",
"additionalProperties": {
"type": "string"
},
"description": "EngineVersions are the installed engines with their versions: llamacpp → b10991. ComputeCapabilities are its GPUs' CUDA compute capabilities, 7.0 for a V100, so an engine build's support can be read against them."
},
"multi_node": {
"type": "string",
"description": "MultiNode is whether it can take part in replicas spanning nodes: \"ready\", \"down: mlx5_1\" naming the declared adapters whose ports are not active, or empty for a node on no fabric. Its own replicas run either way."
},
"compute_capabilities": {
"type": "array",
"items": {
"type": "string"
}
},
"provision": {
"type": "array",
"items": {
@@ -5401,6 +5593,9 @@
"guard_category": {
"type": "string"
},
"guard_rule": {
"type": "string"
},
"guard_ms": {
"type": "integer",
"format": "int64"
@@ -5459,6 +5654,13 @@
"in_place": {
"type": "boolean",
"description": "InPlace stops each replica before starting its replacement, rather than starting the replacement beside it first."
},
"max_surge": {
"type": "integer",
"description": "MaxSurge is how many of a model's replicas are replaced beside themselves at once, MaxUnavailable how many are stopped first at once; both zero is one at a time, replaced beside itself."
},
"max_unavailable": {
"type": "integer"
}
}
},
@@ -5682,6 +5884,10 @@
"memory_bytes": {
"type": "integer",
"format": "int64"
},
"compute": {
"$ref": "#/components/schemas/ComputeView",
"description": "Compute is where the engine said it put the model as it became ready: absent when it did not say."
}
}
},
@@ -5779,6 +5985,14 @@
"$ref": "#/components/schemas/JudgeView"
},
"description": "Judges are the guardrails' judge models with something wrong."
},
"stopped": {
"type": "boolean",
"description": "Stopped says the operators have stopped the service (llmgw stop): no model runs, and requests for one are answered 503."
},
"guard_off": {
"type": "boolean",
"description": "GuardOff says the guardrails are turned off (guard.enabled: false): no request is checked, whatever the policies say."
}
}
},
@@ -6128,7 +6342,7 @@
"properties": {
"format": {
"type": "string",
"description": "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\": [\"...\"]})."
"description": "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": {
"type": "array",
@@ -6306,6 +6520,10 @@
"engine": {
"type": "string"
},
"engine_version": {
"type": "string",
"description": "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": {
"type": "string"
},
@@ -7304,7 +7522,11 @@
},
"guard_format": {
"type": "string",
"description": "GuardFormat makes it a judge model for the guardrails (role: guard): llamaguard3, shieldgemma or json. Empty serves clients."
"description": "GuardFormat makes it a judge model for the guardrails (role: guard): llamaguard3, qwen3guard, shieldgemma or json. Empty serves clients."
},
"class": {
"type": "string",
"description": "Class is what it may take from others and they from it when there is not room for both: critical, standard or opportunistic. Empty is the catalog's default for its role."
},
"ctx_pool": {
"type": "integer",
+115 -23
View File
@@ -4,7 +4,7 @@ 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
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
@@ -15,6 +15,19 @@ checked and nothing costs anything. With one, every request is judged once it is
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
@@ -101,60 +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. To serve it:
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 # Llama Guard 3 1B on CPUs, under the name the policies ask
llmgw ctl guard judge enable --replace # when a model of that name is there, but not as a judge
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 Llama Guard file
served from the mirror page is served as a judge too, under the name the
policies ask. By hand, the catalog entry is:
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": [...]}`.
@@ -164,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
+40 -2
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.
@@ -2294,6 +2295,43 @@ 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
```
+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).
+114 -5
View File
@@ -102,9 +102,11 @@ rather than repeated.
| `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) |
@@ -203,6 +205,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) |
@@ -288,6 +291,8 @@ 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) |
| `firewall_networks` | `[]string` | [Server](#server) |
| `backup_recipients` | `[]string` | [Server](#server) |
@@ -300,6 +305,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) |
@@ -372,8 +378,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) |
@@ -984,9 +996,11 @@ every node and control plane within seconds.
`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`
@@ -1003,6 +1017,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`
@@ -1021,12 +1044,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
@@ -1755,6 +1785,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`
@@ -2456,6 +2494,24 @@ 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`
@@ -2554,6 +2610,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`
@@ -3067,6 +3135,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`
@@ -3075,6 +3150,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`
+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
}
+114 -3
View File
@@ -371,6 +371,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)
}
@@ -411,6 +417,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() {
@@ -436,6 +453,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),
})
@@ -564,8 +582,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()
@@ -861,6 +880,14 @@ func (a *Agent) handle(ctx context.Context, msg *llmgwv1.ControlMessage, out cha
a.setControlPlanes(m.ControlPlanes)
case *llmgwv1.ControlMessage_Firewall:
go a.applyFirewall(m.Firewall)
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
@@ -1130,6 +1157,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 {
@@ -1142,7 +1177,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.
}
@@ -1155,7 +1202,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{}
@@ -1164,6 +1218,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
@@ -1373,3 +1445,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()
+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
+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")
}
}
+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)
}
}
+2
View File
@@ -237,6 +237,8 @@ func (w *settingsWatch) check(ctx context.Context) {
// 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",
// Read by server init, node add and backups, not by the server.
+48 -7
View File
@@ -175,7 +175,7 @@ func newCtlCmd() *cobra.Command {
newJobsCmd(client, &asJSON),
newEngineCheckCmd(),
newEngineBuildCmd(client),
newToolCheckCmd(),
newToolCheckCmd(client),
newWeightsCmd(client, &asJSON),
newCapacityCmd(client, &asJSON),
newQuotaCmd(client, &asJSON),
@@ -232,8 +232,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()
@@ -450,7 +450,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 {
@@ -468,8 +468,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
@@ -479,7 +485,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))
}
}
@@ -590,7 +596,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
+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
}
+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)
}
}
+33
View File
@@ -3,11 +3,13 @@ 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 {
@@ -25,6 +27,34 @@ func newGuardCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
"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",
@@ -38,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 == "" {
+262 -66
View File
@@ -4,20 +4,18 @@ 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"
)
// The judge the guardrails are installed with: Meta's Llama Guard 3 1B,
// from the repository server init fetches it from.
const (
guardModelRepo = "QuantFactory/Llama-Guard-3-1B-GGUF"
guardModelFile = "Llama-Guard-3-1B.Q4_K_M.gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
)
func newGuardJudgeCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
@@ -27,9 +25,15 @@ func newGuardJudgeCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
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).",
"(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))
c.AddCommand(newGuardJudgeStatusCmd(client, asJSON), newGuardJudgeEnableCmd(client),
newGuardJudgeUseCmd(client), newGuardJudgeAskCmd(client, asJSON))
return c
}
@@ -61,23 +65,25 @@ func newGuardJudgeStatusCmd(client func() *ctlClient, asJSON *bool) *cobra.Comma
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)
for _, s := range strays {
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", s, s)
}
sayStrays(out, strays)
return nil
},
}
}
// strayJudges are models whose weights are a judge's but which are in the
// catalog as models clients use, under a name no policy asks: what serving
// Llama Guard from the mirror page did before it knew better.
func strayJudges(cmd *cobra.Command, client func() *ctlClient, js []adminapi.JudgeView) ([]string, error) {
// 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
@@ -86,33 +92,52 @@ func strayJudges(cmd *cobra.Command, client func() *ctlClient, js []adminapi.Jud
for _, j := range js {
asked[j.Model] = true
}
var out []string
var out []adminapi.ModelView
for _, m := range models {
if !asked[m.Name] && strings.Contains(strings.ToLower(m.Source), "llama-guard") {
out = append(out, m.Name)
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 string
replicas, cores int
replace, detach bool
model, repo, file, format, gpuClass string
replicas, cores int
gpu, replace, detach bool
)
c := &cobra.Command{
Use: "enable",
Short: "Serve the judge the policies ask, as a judge: Llama Guard 3 1B on CPUs",
Long: "Fetches Llama Guard 3 1B into the mirror (once), adds it to the catalog under\n" +
"the name the policies give its judge, as a judge (role: guard,\n" +
"format llamaguard3), on CPUs so it takes no card, and waits for a replica\n" +
"to answer. With no --model, the judge the policies ask that is missing.\n\n" +
"A model of that name already in the catalog as a model clients use is\n" +
"refused; --replace removes it first. An offline cluster gets its judge\n" +
"from its bundle with server init.",
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 --replace",
" 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()
@@ -121,45 +146,72 @@ func newGuardJudgeEnableCmd(client func() *ctlClient) *cobra.Command {
if err := client().get(ctx, "/api/v1/guard/judges", &js); err != nil {
return err
}
var target *adminapi.JudgeView
for i, j := range js {
if (model != "" && j.Model == model) || (model == "" && (!j.InCatalog || !j.Judge)) {
target = &js[i]
break
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 target == nil && model != "":
target = &adminapi.JudgeView{Model: model}
fmt.Fprintf(out, "no policy asks %s yet; it is served, and a policy's judge.model names it\n", model)
case target == nil && len(js) == 0:
return errors.New("no policy asks a judge: give one a judge (llmgw ctl config edit, guard.policies.<name>.judge.model)")
case target == nil:
fmt.Fprintln(out, "every judge the policies ask is in the catalog, as a judge")
return nil
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 target.InCatalog && target.Judge {
fmt.Fprintf(out, "%s is in the catalog as a judge already; llmgw ctl guard judge status says how it is\n", target.Model)
return nil
if gpu {
k.GPU = true
}
if target.InCatalog && !target.Judge {
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", target.Model)
"--replace removes it and serves it again as a judge", name)
}
var res map[string]string
if err := client().delete(ctx, "/api/v1/models/"+target.Model, &res); err != nil {
return fmt.Errorf("removing %s: %w", target.Model, err)
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", target.Model)
fmt.Fprintf(out, "%s removed\n", name)
}
req := modelserve.Request{
Repo: repo, File: file, Name: target.Model, GuardFormat: "llamaguard3",
Description: "The guardrails' judge: reads prompts and answers for unsafe content. Not offered to clients.",
CPU: true, Replicas: replicas, CPUCores: cores,
CtxPool: 8192, ContextLength: 4096,
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)
}
}
body, _ := json.Marshal(req)
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
@@ -170,20 +222,164 @@ func newGuardJudgeEnableCmd(client func() *ctlClient) *cobra.Command {
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 {
fmt.Fprintf(out, "\nno policy asks %s, served from a judge's weights as a model clients use: "+
"llmgw ctl model remove %s\n", strings.Join(strays, ", "), strings.Join(strays, " "))
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 (default: the one the policies ask that is missing)")
f.StringVar(&repo, "repo", guardModelRepo, "the registry repository its weights come from")
f.StringVar(&file, "file", guardModelFile, "the file in it")
f.IntVar(&replicas, "replicas", 2, "replicas: every judged request waits on one")
f.IntVar(&cores, "cores", 4, "CPU cores a replica computes on")
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)
}
+4
View File
@@ -63,6 +63,10 @@ func Root() *cobra.Command {
newWipeCmd(),
newDocsCmd(),
)
// status, start, stop and restart: the whole system; service: this
// machine's services.
root.AddCommand(newSystemCmds()...)
root.AddCommand(newServiceCmd())
return root
}
+30 -1
View File
@@ -186,6 +186,7 @@ func newServerCmd() *cobra.Command {
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
@@ -252,6 +253,8 @@ func newServerCmd() *cobra.Command {
}
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()
@@ -362,11 +365,14 @@ func newServerCmd() *cobra.Command {
api.SetEval(evals)
if st := rt.Store(); st != nil && gate != nil {
api.SetGuard(&adminapi.GuardAdmin{Library: gate.Library, Store: st, Invalidate: gate.Invalidate,
JudgeErrors: gate.JudgeErrors})
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)
// The cluster's settings, each change checked against this
// machine's own keys before it is kept.
@@ -512,6 +518,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)
}
@@ -676,6 +697,14 @@ func newServerCmd() *cobra.Command {
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)
+12 -9
View File
@@ -293,7 +293,7 @@ func (in *serverInit) configJudgesWith(model string) bool {
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
`
}
+8 -12
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
@@ -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 {
+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())
}
}
+6
View File
@@ -153,6 +153,12 @@ 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 {
+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\"}"}}]}}]}`))
+6 -2
View File
@@ -1230,13 +1230,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"
+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
}
+12
View File
@@ -19,6 +19,7 @@ import (
"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"
@@ -146,6 +147,14 @@ 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"`
@@ -914,6 +923,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
}
+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"`
}
+101 -9
View File
@@ -12,6 +12,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/url"
"slices"
@@ -69,6 +70,8 @@ type API struct {
// guard is the guardrail policies' library and attachments.
guard *GuardAdmin
settings *Settings
// restartAgents tells every connected agent to start again.
restartAgents func(reason string) []string
// revoke records a removed node, so its certificate is refused.
revoke func(ctx context.Context, nodeID string, at time.Time) error
@@ -113,6 +116,7 @@ func (a *API) Register(mux Mux) {
a.registerQuotas(mux)
a.registerGuard(mux)
a.registerSettings(mux)
mux.HandleFunc("POST /api/v1/agents/restart", a.restartAgentsHandler)
mux.HandleFunc("GET /api/v1/audit", a.auditEvents)
mux.HandleFunc("GET /api/v1/usage/export", a.usageExport)
mux.HandleFunc("POST /api/v1/notify/test", a.notifyTest)
@@ -228,6 +232,17 @@ type NodeView struct {
// which is not the same as having none — placement treats it that way too,
// or a control plane upgrade would empty a fleet.
Engines []string `json:"engines,omitempty"`
// EngineVersions are the installed engines with their versions:
// llamacpp → b10991. ComputeCapabilities are its GPUs' CUDA compute
// capabilities, 7.0 for a V100, so an engine build's support can be read
// against them.
EngineVersions map[string]string `json:"engine_versions,omitempty"`
// MultiNode is whether it can take part in replicas spanning nodes:
// "ready", "down: mlx5_1" naming the declared adapters whose ports are
// not active, or empty for a node on no fabric. Its own replicas run
// either way.
MultiNode string `json:"multi_node,omitempty"`
ComputeCapabilities []string `json:"compute_capabilities,omitempty"`
// Provision is where installing each wanted engine stands. An engine is in
// Engines only once its installation here is ready.
Provision []inventory.EngineInstall `json:"provision,omitempty"`
@@ -416,10 +431,12 @@ type ModelView struct {
Description string `json:"description,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Source string `json:"source"`
Min int `json:"min_replicas"`
Max int `json:"max_replicas"`
Ready int `json:"ready_replicas"`
Total int `json:"total_replicas"`
// Role is "guard" for a guardrails judge, empty for a model clients use.
Role string `json:"role,omitempty"`
Min int `json:"min_replicas"`
Max int `json:"max_replicas"`
Ready int `json:"ready_replicas"`
Total int `json:"total_replicas"`
// Desired is how many it should have now: Min, or for an on-demand
// model one while it is being asked for and none otherwise.
Desired int `json:"desired_replicas"`
@@ -518,6 +535,30 @@ type ShardView struct {
// node's last heartbeat; zero when not measured.
CPUCores float64 `json:"cpu_cores,omitempty"`
MemoryBytes int64 `json:"memory_bytes,omitempty"`
// Compute is where the engine said it put the model as it became
// ready: absent when it did not say.
Compute *ComputeView `json:"compute,omitempty"`
}
// ComputeView is where an engine runs its model, by its own account.
type ComputeView struct {
// Backend is CUDA, Vulkan, Metal, ROCm, GPU or CPU.
Backend string `json:"backend"`
Devices []string `json:"devices,omitempty"`
// LayersOffloaded of LayersTotal went to the devices; absent when unsaid.
LayersOffloaded int `json:"layers_offloaded,omitempty"`
LayersTotal int `json:"layers_total,omitempty"`
}
// Short is the compute in a few characters: "CUDA 49/49", "CPU".
func (c *ComputeView) Short() string {
if c == nil {
return "-"
}
if c.LayersTotal > 0 && c.Backend != "CPU" {
return fmt.Sprintf("%s %d/%d", c.Backend, c.LayersOffloaded, c.LayersTotal)
}
return c.Backend
}
// FetchView is a download in flight.
@@ -623,6 +664,16 @@ func (a *API) Nodes() []NodeView {
v.Engines = append(v.Engines, name)
}
slices.Sort(v.Engines)
if len(n.EngineVersions) > 0 {
v.EngineVersions = maps.Clone(n.EngineVersions)
}
for _, g := range n.GPUs {
if g.ComputeCapability != "" && !slices.Contains(v.ComputeCapabilities, g.ComputeCapability) {
v.ComputeCapabilities = append(v.ComputeCapabilities, g.ComputeCapability)
}
}
slices.Sort(v.ComputeCapabilities)
v.MultiNode = multiNodeReadiness(n)
v.Provision = n.Provision
if ch := a.ctl.EngineChoices(); ch != nil && len(n.GPUs) > 0 {
chosen := ch.For(n.ID)
@@ -761,6 +812,7 @@ func (a *API) Models() []ModelView {
Class: m.EffectiveClass(), Owner: m.Owner, Preempted: h.Preempted, HeldFor: h.HeldFor,
External: externalLabel(m), Adapters: m.Adapters,
Canary: m.Canary, CanaryOf: a.ctl.Catalog().CanaryOf(m.Name), Eval: m.Eval,
Role: m.Role,
}
for _, p := range m.Profiles {
v.Profiles = append(v.Profiles, p.Name)
@@ -829,6 +881,10 @@ func (a *API) Replicas(model string) []ReplicaView {
sv.Fetch.Percent = &p
}
}
if c := s.Compute; c != nil {
sv.Compute = &ComputeView{Backend: c.Backend, Devices: c.Devices,
LayersOffloaded: c.Offloaded, LayersTotal: c.Total}
}
if l := loads[s.NodeID]; l != nil {
if use, ok := l.Shards[s.ID]; ok {
sv.CPUCores, sv.MemoryBytes = use.CPUCores, use.RSSBytes
@@ -1473,6 +1529,11 @@ type RestartRequest struct {
// InPlace stops each replica before starting its replacement, rather
// than starting the replacement beside it first.
InPlace bool `json:"in_place,omitempty"`
// MaxSurge is how many of a model's replicas are replaced beside
// themselves at once, MaxUnavailable how many are stopped first at once;
// both zero is one at a time, replaced beside itself.
MaxSurge int `json:"max_surge,omitempty"`
MaxUnavailable int `json:"max_unavailable,omitempty"`
}
// RestartResult lists the replicas queued.
@@ -1496,11 +1557,12 @@ func (a *API) restart(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "nothing ready matches; name replicas, models, or all")
return
}
restart := a.ctl.Reconciler().Restart
if req.InPlace {
restart = a.ctl.Reconciler().RestartInPlace
if req.MaxSurge < 0 || req.MaxUnavailable < 0 {
writeError(w, http.StatusBadRequest, "max_surge and max_unavailable are counts of replicas, not negative")
return
}
if err := restart(ids...); err != nil {
pace := reconciler.RestartPace{MaxSurge: req.MaxSurge, MaxUnavailable: req.MaxUnavailable}
if err := a.ctl.Reconciler().RestartPaced(pace, req.InPlace, ids...); err != nil {
writeError(w, http.StatusNotFound, err.Error())
return
}
@@ -1545,6 +1607,7 @@ type RequestView struct {
GuardAction string `json:"guard_action,omitempty"`
GuardPolicy string `json:"guard_policy,omitempty"`
GuardCategory string `json:"guard_category,omitempty"`
GuardRule string `json:"guard_rule,omitempty"`
GuardMs int64 `json:"guard_ms,omitempty"`
// What the client said about it: OpenAI's user field, and its tags.
EndUser string `json:"end_user,omitempty"`
@@ -1573,7 +1636,7 @@ func (a *API) request(w http.ResponseWriter, r *http.Request) {
Model: u.Model, ServedModel: u.ServedModel, Replica: u.Replica, Status: u.Status,
LatencyMs: u.LatencyMs, QueueMs: u.QueueMs, TTFTMs: u.TTFTMs,
PromptTokens: u.PromptTokens, CompletionTokens: u.CompletionTokens, CostMicros: u.CostMicros,
GuardAction: u.GuardAction, GuardPolicy: u.GuardPolicy, GuardCategory: u.GuardCategory, GuardMs: u.GuardMs,
GuardAction: u.GuardAction, GuardPolicy: u.GuardPolicy, GuardCategory: u.GuardCategory, GuardRule: u.GuardRule, GuardMs: u.GuardMs,
EndUser: u.EndUser, Tags: u.Tags, CacheHit: u.CacheHit,
})
}
@@ -1975,3 +2038,32 @@ func (a *API) SetNodeEngine(ctx context.Context, node, engine, build, by string)
}
return ch.Set(ctx, node, engine, build, by)
}
// multiNodeReadiness is whether a node's declared fabrics are up: every
// adapter they name with an active port. Empty for a node on no fabric.
func multiNodeReadiness(n inventory.Node) string {
if len(n.Fabrics) == 0 {
return ""
}
if len(n.IBPorts) == 0 {
return "unknown: no port reported yet"
}
var down []string
for _, f := range n.Fabrics {
for _, hca := range f.HCAs {
up := false
for _, p := range n.IBPorts {
if p.HCA == hca && p.Active() {
up = true
}
}
if !up && !slices.Contains(down, hca) {
down = append(down, hca)
}
}
}
if len(down) > 0 {
return "down: " + strings.Join(down, ", ")
}
return "ready"
}
+11 -3
View File
@@ -30,6 +30,8 @@ type GuardAdmin struct {
Invalidate func()
// JudgeErrors are each judge's last failure to give a verdict.
JudgeErrors func() map[string]guard.JudgeError
// Ask has a judge read a text, past the cache.
Ask func(ctx context.Context, model, text, answer string) (guard.JudgeAnswer, error)
}
// SetGuard enables the guard endpoints.
@@ -37,6 +39,8 @@ func (a *API) SetGuard(g *GuardAdmin) { a.guard = g }
// GuardView is the library and where its policies are attached.
type GuardView struct {
// Enabled is whether the guardrails check anything (guard.enabled).
Enabled bool `json:"enabled"`
Policies []string `json:"policies"`
Default []string `json:"default"`
Attachments []GuardAttachmentView `json:"attachments"`
@@ -91,7 +95,10 @@ type GuardEventView struct {
Action string `json:"action"`
Policy string `json:"policy"`
Category string `json:"category,omitempty"`
GuardMs int64 `json:"guard_ms"`
// Rule is what decided it within the category: a pattern's name, or a
// judge's verdict, "S6,Specialized Advice".
Rule string `json:"rule,omitempty"`
GuardMs int64 `json:"guard_ms"`
}
// policyView sums one policy up.
@@ -201,6 +208,7 @@ type GuardAttachmentRequest struct {
func (a *API) registerGuard(mux Mux) {
mux.HandleFunc("GET /api/v1/guard/judges", a.listJudges)
mux.HandleFunc("POST /api/v1/guard/judges/ask", a.askJudge)
mux.HandleFunc("GET /api/v1/guard", func(w http.ResponseWriter, r *http.Request) {
if a.guard == nil {
writeError(w, http.StatusNotImplemented, "no guard policies: define them under guard in the cluster's settings (llmgw ctl config edit)")
@@ -211,7 +219,7 @@ func (a *API) registerGuard(mux Mux) {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
v := GuardView{Default: a.guard.Library().Default, Attachments: []GuardAttachmentView{}}
v := GuardView{Enabled: a.guard.Library().On(), Default: a.guard.Library().Default, Attachments: []GuardAttachmentView{}}
var names map[string]string
if a.quotas != nil {
names, _ = a.scopeNames(r.Context())
@@ -272,7 +280,7 @@ func (a *API) registerGuard(mux Mux) {
slices.SortStableFunc(v.Counts, func(a, b GuardCountView) int { return int(b.Count - a.Count) })
for _, e := range events {
v.Events = append(v.Events, GuardEventView{At: e.At, RequestID: e.RequestID, Project: project(e.ProjectID),
Model: e.Model, Status: e.Status, Action: e.Action, Policy: e.Policy, Category: e.Category, GuardMs: e.GuardMs})
Model: e.Model, Status: e.Status, Action: e.Action, Policy: e.Policy, Category: e.Category, Rule: e.Rule, GuardMs: e.GuardMs})
}
writeJSON(w, http.StatusOK, v)
})
+88 -12
View File
@@ -1,10 +1,12 @@
package adminapi
import (
"encoding/json"
"fmt"
"net/http"
"slices"
"sort"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
@@ -19,6 +21,9 @@ type JudgeView struct {
// cannot read (fail: closed).
Policies []string `json:"policies"`
Closed []string `json:"closed,omitempty"`
// Then are the judges its policies ask after it, when it gives no
// verdict: a CPU judge behind a GPU one.
Then []string `json:"then,omitempty"`
// InCatalog is whether a model of that name is in the catalog, and
// Judge whether it is there as a judge (role: guard).
InCatalog bool `json:"in_catalog"`
@@ -44,19 +49,30 @@ func (a *API) Judges() []JudgeView {
return nil
}
lib := a.guard.Library()
if !lib.On() {
// Off: no judge is asked, so none is missing.
return nil
}
byModel := map[string]*JudgeView{}
for name, p := range lib.Policies {
if p.Judge == nil || p.Judge.Model == "" {
continue
}
v := byModel[p.Judge.Model]
if v == nil {
v = &JudgeView{Model: p.Judge.Model}
byModel[p.Judge.Model] = v
}
v.Policies = append(v.Policies, name)
if p.Fail == "closed" && p.Mode != "shadow" {
v.Closed = append(v.Closed, name)
judges := p.Judge.Judges()
for i, model := range judges {
v := byModel[model]
if v == nil {
v = &JudgeView{Model: model}
byModel[model] = v
}
if !slices.Contains(v.Policies, name) {
v.Policies = append(v.Policies, name)
}
if p.Fail == "closed" && p.Mode != "shadow" && !slices.Contains(v.Closed, name) {
v.Closed = append(v.Closed, name)
}
for _, after := range judges[i+1:] {
if !slices.Contains(v.Then, after) {
v.Then = append(v.Then, after)
}
}
}
}
ready := map[string]int{}
@@ -98,7 +114,10 @@ func (a *API) Judges() []JudgeView {
func judgeProblem(v JudgeView) string {
consequence := "requests its policies judge are served unjudged"
if len(v.Closed) > 0 {
switch {
case len(v.Then) > 0:
consequence = "its policies ask " + strings.Join(v.Then, ", else ") + " instead"
case len(v.Closed) > 0:
consequence = "requests its policies judge are refused (guard_unavailable)"
}
switch {
@@ -122,3 +141,60 @@ func (a *API) listJudges(w http.ResponseWriter, _ *http.Request) {
}
writeJSON(w, http.StatusOK, a.Judges())
}
// JudgeAskRequest is a text to have a judge read.
type JudgeAskRequest struct {
// Model is the judge; empty is the one the policies ask, when they ask one.
Model string `json:"model,omitempty"`
Text string `json:"text"`
// Answer, when given, is read as the assistant's reply to Text.
Answer string `json:"answer,omitempty"`
}
// JudgeAskView is what the judge answered.
type JudgeAskView struct {
Model string `json:"model"`
// Verdict is safe, unsafe or controversial, as the gateway reads the
// answer; a policy's judge.controversial says what controversial counts as.
Verdict string `json:"verdict"`
Categories []string `json:"categories,omitempty"`
// Raw is the judge's answer as it gave it.
Raw string `json:"raw"`
TookMs int64 `json:"took_ms"`
}
func (a *API) askJudge(w http.ResponseWriter, r *http.Request) {
if a.guard == nil || a.guard.Ask == nil {
writeError(w, http.StatusNotImplemented, "no guard policies: define them under guard in the cluster's settings (llmgw ctl config edit)")
return
}
var req JudgeAskRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Text) == "" {
writeError(w, http.StatusBadRequest, "give the text for the judge to read")
return
}
if req.Model == "" {
judges := a.Judges()
if len(judges) == 0 {
writeError(w, http.StatusBadRequest, "no policy asks a judge: give the model")
return
}
req.Model = judges[0].Model
}
ans, err := a.guard.Ask(r.Context(), req.Model, req.Text, req.Answer)
v := JudgeAskView{Model: req.Model, Raw: ans.Raw, Categories: ans.Categories, TookMs: ans.Took.Milliseconds()}
if err != nil {
writeError(w, http.StatusBadGateway, fmt.Sprintf("the judge %s gave no verdict: %v (it answered %q)", req.Model, err, ans.Raw))
return
}
switch {
case ans.Unsafe:
v.Verdict = "unsafe"
case ans.Controversial:
// Qwen3Guard's middle verdict: what it counts as is the policy's.
v.Verdict = "controversial"
default:
v.Verdict = "safe"
}
writeJSON(w, http.StatusOK, v)
}
+59
View File
@@ -1,11 +1,13 @@
package adminapi
import (
"context"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
)
@@ -64,3 +66,60 @@ func TestEachJudgeSaysWhatIsWrongWithIt(t *testing.T) {
t.Errorf("an old failure still counts: %q", p)
}
}
func TestAJudgeIsAskedAndItsWordsShown(t *testing.T) {
a, _ := setup(t, testModels())
lib := guard.Config{Policies: map[string]guard.Policy{
"baseline": {Judge: &guard.JudgePolicy{Model: "llama-guard-3-1b", Input: "block"}}}}
var asked string
a.SetGuard(&GuardAdmin{Library: func() guard.Config { return lib },
Ask: func(_ context.Context, model, text, _ string) (guard.JudgeAnswer, error) {
asked = model + "|" + text
return guard.JudgeAnswer{Model: model, Raw: "unsafe\nS6", Unsafe: true,
Categories: []string{"S6", "Specialized Advice"}}, nil
}})
var v JudgeAskView
if code := call(t, a, "POST", "/api/v1/guard/judges/ask", `{"text":"how do I sort a list"}`, &v); code != 200 {
t.Fatalf("%d", code)
}
if asked != "llama-guard-3-1b|how do I sort a list" || v.Verdict != "unsafe" || v.Raw != "unsafe\nS6" ||
len(v.Categories) != 2 {
t.Errorf("asked %q, got %+v", asked, v)
}
if code := call(t, a, "POST", "/api/v1/guard/judges/ask", `{"text":" "}`, nil); code != 400 {
t.Errorf("no text: %d", code)
}
}
func TestANodeSaysItsEnginesVersionsAndCapabilities(t *testing.T) {
n := gpuNode("volta", 2)
n.EngineVersions = map[string]string{"llamacpp": "b10991", "vllm": "0.10.2"}
for i := range n.GPUs {
n.GPUs[i].ComputeCapability = "7.0"
}
a, _ := setup(t, testModels(), n)
var nodes []NodeView
call(t, a, "GET", "/api/v1/nodes", "", &nodes)
if len(nodes) != 1 || nodes[0].EngineVersions["vllm"] != "0.10.2" ||
len(nodes[0].ComputeCapabilities) != 1 || nodes[0].ComputeCapabilities[0] != "7.0" {
t.Errorf("%+v", nodes)
}
}
func TestANodeSaysWhetherItsFabricsAreUp(t *testing.T) {
n := gpuNode("ib1", 2, "ib-a")
if got := multiNodeReadiness(n); !strings.HasPrefix(got, "unknown") {
t.Errorf("no port yet: %q", got)
}
n.IBPorts = []inventory.IBPort{{HCA: "mlx5_0", State: "DOWN"}}
if got := multiNodeReadiness(n); got != "down: mlx5_0" {
t.Errorf("down: %q", got)
}
n.IBPorts[0].State = "ACTIVE"
if got := multiNodeReadiness(n); got != "ready" {
t.Errorf("up: %q", got)
}
if got := multiNodeReadiness(gpuNode("plain", 1)); got != "" {
t.Errorf("no fabric: %q", got)
}
}
+5
View File
@@ -256,11 +256,16 @@ var Routes = []Route{
Response: GuardEventsView{}},
{Method: "GET", Path: "/api/v1/guard/judges", Summary: "The judge models the policies ask, and what is wrong with each",
Response: []JudgeView{}},
{Method: "POST", Path: "/api/v1/guard/judges/ask", Summary: "Have a judge read a text and say what it answered; nothing is kept",
Request: JudgeAskRequest{}, Response: JudgeAskView{}},
{Method: "POST", Path: "/api/v1/guard/attachments", Summary: "Attach a guardrail policy",
Request: GuardAttachmentRequest{}, Response: guard.Attachment{}},
{Method: "DELETE", Path: "/api/v1/guard/attachments", Summary: "Detach a guardrail policy",
Request: GuardAttachmentRequest{}, Response: guard.Attachment{}},
{Method: "POST", Path: "/api/v1/agents/restart", Summary: "Have every node's agent start again; their engines keep serving",
Response: AgentRestartView{}},
// The cluster's settings: everything in server.yaml that is not one
// machine's own, the same on every control plane.
{Method: "GET", Path: "/api/v1/settings", Summary: "The cluster's settings in force, or one earlier version",
+10 -1
View File
@@ -44,6 +44,12 @@ type Summary struct {
ControlPlanes ControlPlanes `json:"control_planes,omitzero"`
// Judges are the guardrails' judge models with something wrong.
Judges []JudgeView `json:"judges,omitempty"`
// Stopped says the operators have stopped the service (llmgw stop): no
// model runs, and requests for one are answered 503.
Stopped bool `json:"stopped,omitempty"`
// GuardOff says the guardrails are turned off (guard.enabled: false):
// no request is checked, whatever the policies say.
GuardOff bool `json:"guard_off,omitempty"`
}
// StoreMember is one member of a replicated store, as its Patroni reports it.
@@ -150,7 +156,10 @@ const idleThreshold = 10 * time.Minute
// Summary builds the fleet summary.
func (a *API) Summary() Summary {
s := Summary{Version: version.Version}
s := Summary{Version: version.Version, Stopped: a.ctl.Reconciler().Stopped()}
if a.guard != nil && a.guard.Library != nil && !a.guard.Library().On() {
s.GuardOff = true
}
for _, j := range a.Judges() {
if j.Problem != "" {
s.Judges = append(s.Judges, j)
+22
View File
@@ -0,0 +1,22 @@
package adminapi
import (
"net/http"
)
// AgentRestartView names the nodes whose agents were told to restart.
type AgentRestartView struct {
Nodes []string `json:"nodes"`
}
// SetAgentRestarter enables POST /api/v1/agents/restart.
func (a *API) SetAgentRestarter(f func(reason string) []string) { a.restartAgents = f }
func (a *API) restartAgentsHandler(w http.ResponseWriter, r *http.Request) {
if a.restartAgents == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no agents to restart")
return
}
nodes := a.restartAgents("llmgw restart, by " + actorOf(r))
writeJSON(w, http.StatusAccepted, AgentRestartView{Nodes: nodes})
}
+4
View File
@@ -169,6 +169,10 @@ func AdoptedFromRegister(nodeID string, shards []*llmgwv1.RunningShard) []reconc
Fabric: s.GetFabric(),
ProfileFP: s.GetProfileFp(),
}
if c := s.GetCompute(); c != nil {
a.Compute = &reconciler.Compute{Backend: c.GetBackend(), Devices: c.GetDevices(),
Offloaded: int(c.GetLayersOffloaded()), Total: int(c.GetLayersTotal())}
}
if a.Rank == 0 && s.GetLeaderAddr() != "" {
a.Endpoint = fmt.Sprintf("http://%s:%d", s.GetLeaderAddr(), s.GetHttpPort())
}
+19
View File
@@ -1,6 +1,8 @@
package agentsvc
import (
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
)
@@ -40,3 +42,20 @@ func (s *Service) BroadcastFirewall() {
}
}
}
// RestartAgents has every connected agent start again, one at a time a
// moment apart, and names those it told. Their engines keep serving.
func (s *Service) RestartAgents(reason string) []string {
msg := &llmgwv1.ControlMessage{Msg: &llmgwv1.ControlMessage_RestartAgent{
RestartAgent: &llmgwv1.RestartAgent{Reason: reason}}}
var told []string
for _, node := range s.ConnectedNodes() {
if err := s.send(node, msg); err != nil {
s.log.Warn("could not tell a node to restart its agent", "node", node, "err", err)
continue
}
told = append(told, node)
time.Sleep(time.Second)
}
return told
}
+12
View File
@@ -36,6 +36,8 @@ type StatusSink interface {
// still starting, and treating each report as a state change would reset
// the timers that decide when a slow start has become a failed one.
OnFetchProgress(replicaID, shardID, source string, done, total int64)
// OnShardCompute records where a shard's engine said it runs the model.
OnShardCompute(replicaID, shardID string, c reconciler.Compute)
Adopt(nodeID string, shards []reconciler.AdoptedShard) (unclaimed []reconciler.AdoptedShard)
}
@@ -447,6 +449,16 @@ func (s *Service) handle(nodeID string, msg *llmgwv1.AgentMessage) {
f.GetSource(), f.GetBytesDone(), f.GetBytesTotal())
return
}
if c := m.Shard.GetCompute(); c != nil {
s.status.OnShardCompute(m.Shard.GetReplicaId(), m.Shard.GetShardId(), reconciler.Compute{
Backend: c.GetBackend(), Devices: c.GetDevices(),
Offloaded: int(c.GetLayersOffloaded()), Total: int(c.GetLayersTotal()),
})
// Compute alone, with no state: not a transition.
if m.Shard.GetState() == llmgwv1.ShardStatus_STATE_UNSPECIFIED {
return
}
}
st := StateFromWire(m.Shard.GetState())
s.status.OnShardStatus(m.Shard.GetReplicaId(), m.Shard.GetShardId(), st, m.Shard.GetError())
if m.Shard.GetState() == llmgwv1.ShardStatus_STATE_FAILED {
+29 -23
View File
@@ -16,6 +16,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/placement"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
)
// Scaling is how many replicas a model should have.
@@ -164,9 +165,13 @@ type SLO struct {
// Profile is how a model runs on one GPU class.
type Profile struct {
Name string `json:"name,omitempty" koanf:"name"`
Engine string `json:"engine,omitempty" koanf:"engine"`
GPUClass string `json:"gpu_class,omitempty" koanf:"gpu_class"`
Name string `json:"name,omitempty" koanf:"name"`
Engine string `json:"engine,omitempty" koanf:"engine"`
// 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.
EngineVersion string `json:"engine_version,omitempty" koanf:"engine_version"`
GPUClass string `json:"gpu_class,omitempty" koanf:"gpu_class"`
TP int `json:"tp,omitempty" koanf:"tp"`
PP int `json:"pp,omitempty" koanf:"pp"`
@@ -382,9 +387,11 @@ func (m Model) IsGuard() bool { return m.Role == RoleGuard }
// GuardModel is how a judge model's answers are read.
type GuardModel struct {
// 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":
// ["..."]}).
Format string `json:"format" koanf:"format"`
// Categories the model is asked about, for shieldgemma — each a policy
// sentence — and the names reported for llamaguard3's S1, S2, ….
@@ -704,12 +711,10 @@ func (m Model) validate() error {
}
case RoleGuard:
if m.Guard == nil {
return fmt.Errorf("model %q: a guard model needs guard.format: llamaguard3, shieldgemma or json", m.Name)
return fmt.Errorf("model %q: a guard model needs guard.format: %s", m.Name, strings.Join(guard.Formats, ", "))
}
switch m.Guard.Format {
case "llamaguard3", "shieldgemma", "json":
default:
return fmt.Errorf("model %q: guard.format %q is not llamaguard3, shieldgemma or json", m.Name, m.Guard.Format)
if !guard.KnownFormat(m.Guard.Format) {
return fmt.Errorf("model %q: guard.format %q is not one of %s", m.Name, m.Guard.Format, strings.Join(guard.Formats, ", "))
}
if m.Scaling.OnDemand() {
// A judge loaded on demand makes the first request of every
@@ -885,18 +890,19 @@ func (m Model) PlacementProfiles() []placement.Profile {
out := make([]placement.Profile, 0, len(m.Profiles))
for _, p := range m.Profiles {
out = append(out, placement.Profile{
Name: p.Name,
Engine: p.Engine,
GPUClass: p.GPUClass,
GPUsPerNode: p.GPUsPerNode,
Nodes: p.Nodes,
Priority: p.Priority,
NodeSelector: p.NodeSelector,
MinMemoryGB: p.MinMemoryGB,
MinDiskGB: p.MinDiskGB,
CPUCores: p.CPUCores,
PowerWatts: p.PowerWatts,
KeepWithin: facility.Level(strings.ToLower(strings.TrimSpace(p.KeepWithin))),
Name: p.Name,
Engine: p.Engine,
EngineVersion: p.EngineVersion,
GPUClass: p.GPUClass,
GPUsPerNode: p.GPUsPerNode,
Nodes: p.Nodes,
Priority: p.Priority,
NodeSelector: p.NodeSelector,
MinMemoryGB: p.MinMemoryGB,
MinDiskGB: p.MinDiskGB,
CPUCores: p.CPUCores,
PowerWatts: p.PowerWatts,
KeepWithin: facility.Level(strings.ToLower(strings.TrimSpace(p.KeepWithin))),
// The model's source, so placement can prefer nodes that already
// hold the weights rather than one that must first move them.
Source: m.Source,
+13 -4
View File
@@ -29,6 +29,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
@@ -137,8 +138,12 @@ type Request struct {
// Replicas to run; 0 makes it on demand, loaded when first asked for.
Replicas int `json:"replicas"`
// GuardFormat makes it a judge model for the guardrails (role: guard):
// llamaguard3, shieldgemma or json. Empty serves clients.
// llamaguard3, qwen3guard, shieldgemma or json. Empty serves clients.
GuardFormat string `json:"guard_format,omitempty"`
// Class is what it may take from others and they from it when there is
// not room for both: critical, standard or opportunistic. Empty is the
// catalog's default for its role.
Class string `json:"class,omitempty"`
// CtxPool is llama.cpp's --ctx-size; ContextLength what one request may
// use. They default to 16384 and 4096, what four parallel slots divide;
// llama.cpp is given as many slots as the pool holds requests.
@@ -252,10 +257,13 @@ func (s *Server) Start(req Request) (Job, error) {
default:
return Job{}, fmt.Errorf("the format is gguf or safetensors, not %q", req.Format)
}
switch req.GuardFormat {
case "", "llamaguard3", "shieldgemma", "json":
if req.GuardFormat != "" && !guard.KnownFormat(req.GuardFormat) {
return Job{}, fmt.Errorf("the guard format is one of %s, not %q", strings.Join(guard.Formats, ", "), req.GuardFormat)
}
switch req.Class {
case "", catalog.ClassCritical, catalog.ClassStandard, catalog.ClassOpportunistic:
default:
return Job{}, fmt.Errorf("the guard format is llamaguard3, shieldgemma or json, not %q", req.GuardFormat)
return Job{}, fmt.Errorf("the class is critical, standard or opportunistic, not %q", req.Class)
}
if req.GuardFormat != "" && req.Replicas == 0 {
return Job{}, errors.New("a judge model must always run: give it --replicas 1 or more, not --on-demand")
@@ -618,6 +626,7 @@ func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.F
if req.GuardFormat != "" {
m.Role, m.Guard, m.RoutingProfile = catalog.RoleGuard, &catalog.GuardModel{Format: req.GuardFormat}, ""
}
m.Class = req.Class
s.step(j, "catalog", Running, "adding "+name)
if err := s.deps.Catalog.Add(ctx, m, req.By); err != nil {
s.fail(j, "catalog", err)
+26 -5
View File
@@ -25,9 +25,12 @@ import (
// Profile is the subset of a catalog profile placement needs.
type Profile struct {
Name string
Engine string
GPUClass string
Name string
Engine string
// EngineVersion is a prefix the node's installed engine version must
// have; empty takes any.
EngineVersion string
GPUClass string
// GPUsPerNode is how many GPUs each node contributes.
GPUsPerNode int
// Nodes is how many nodes the replica spans. Above 1, a fabric is required.
@@ -614,8 +617,13 @@ func (s *Scheduler) eligibleNodes(p Profile, fabric string, avoid []string, reje
// and either way not a place to start an engine. sim is built into
// the agent and never installed.
if p.Engine != "" && p.Engine != "sim" && (n.EnginesProbed || len(n.EngineVersions) > 0) {
if _, ok := n.EngineVersions[p.Engine]; !ok {
reject("does not have " + p.Engine + " installed" + installing(n, p.Engine))
v, ok := n.EngineVersions[p.Engine]
if !ok {
reject("does not have " + p.Engine + " installed" + installing(n, p.Engine) + hasEngines(n))
continue
}
if p.EngineVersion != "" && !strings.HasPrefix(v, p.EngineVersion) {
reject(fmt.Sprintf("has %s %s, not the %s the profile asks for", p.Engine, v, p.EngineVersion))
continue
}
}
@@ -828,6 +836,19 @@ func (s *Scheduler) claimedCores(nodeID string) int {
}
// installing says why an engine is missing, when the node has said.
// hasEngines is what a node does have, for a rejection over what it does not.
func hasEngines(n inventory.Node) string {
if len(n.EngineVersions) == 0 {
return ""
}
var parts []string
for e, v := range n.EngineVersions {
parts = append(parts, e+" "+v)
}
slices.Sort(parts)
return "; it has " + strings.Join(parts, ", ")
}
func installing(n inventory.Node, engine string) string {
for _, p := range n.Provision {
if p.Engine == engine && p.Detail != "" {
@@ -1071,3 +1071,31 @@ func TestANodeWithoutIMEXIsNotCountedInItsDomain(t *testing.T) {
t.Errorf("the explanation should say why the domain was passed over:\n%s", res.Explain())
}
}
func TestAProfileCanAskForAnEngineVersion(t *testing.T) {
old := withEngines("volta", 8, "vllm", "llamacpp")
old.EngineVersions["vllm"] = "0.10.2"
newer := withEngines("hopper", 8, "vllm")
newer.EngineVersions["vllm"] = "0.11.1"
s := New(store(old, newer))
res := s.Place([]Profile{{Name: "tp1", Engine: "vllm", EngineVersion: "0.11", GPUClass: "h100-80g",
GPUsPerNode: 1, Nodes: 1}}, nil)
if res.Placement == nil || res.Placement.Nodes[0].NodeID != "hopper" {
t.Fatalf("placed where the version is not:\n%s", res.Explain())
}
only := New(store(old))
res = only.Place([]Profile{{Name: "tp1", Engine: "vllm", EngineVersion: "0.11", GPUClass: "h100-80g",
GPUsPerNode: 1, Nodes: 1}}, nil)
if res.Placement != nil || !strings.Contains(res.Explain(), "has vllm 0.10.2, not the 0.11") {
t.Errorf("the rejection should say which version is there:\n%s", res.Explain())
}
// A node without the engine says what it has instead.
res = New(store(withEngines("cpu-only", 8, "llamacpp"))).Place([]Profile{{Name: "tp1", Engine: "vllm",
GPUClass: "h100-80g", GPUsPerNode: 1, Nodes: 1}}, nil)
if !strings.Contains(res.Explain(), "it has llamacpp") {
t.Errorf("the rejection should say what the node has:\n%s", res.Explain())
}
}
+10 -1
View File
@@ -130,13 +130,19 @@ func (r *Reconciler) mergeAdopted(rep *Replica, group []AdoptedShard) {
}
for _, s := range group {
if _, dup := have[s.ShardID]; dup {
// Known already, but what it computes on may not be.
for i := range rep.Shards {
if rep.Shards[i].ID == s.ShardID && rep.Shards[i].Compute == nil {
rep.Shards[i].Compute = s.Compute
}
}
continue
}
have[s.ShardID] = struct{}{}
rep.Shards = append(rep.Shards, Shard{
ID: s.ShardID, NodeID: s.NodeID, Rank: s.Rank,
GPUIndices: s.GPUIndices, HCAs: s.HCAs,
HTTPPort: s.HTTPPort, State: s.State,
HTTPPort: s.HTTPPort, State: s.State, Compute: s.Compute,
})
// Reclaim the GPUs and the memory, or the next placement hands them
// out twice. The memory comes from the catalog rather than the agent:
@@ -254,6 +260,9 @@ type AdoptedShard struct {
ProfileFP string
// Endpoint is the leader's base URL, set on the rank 0 shard.
Endpoint string
// Compute is where the engine said it put the model, when the agent
// kept that.
Compute *Compute
}
// adoptedCores is what an adopted shard is taken to hold of its node's CPUs.
+4
View File
@@ -81,6 +81,10 @@ func (r *Reconciler) Want(model string) bool {
// feature: a cluster with more models than GPUs can hold every model in its
// catalog and only the wanted ones in its cards.
func (r *Reconciler) desiredFor(m catalog.Model) int {
// Stopped by the operators: nothing runs, whatever the catalog asks.
if r.stopped.Load() {
return 0
}
base := r.baseFor(m)
if base == 0 {
return 0
@@ -46,3 +46,36 @@ func TestWithoutLeadershipConfiguredNothingChanges(t *testing.T) {
t.Error("a reconciler with no leadership configured placed nothing")
}
}
// switchLeader is leadership that can change hands.
type switchLeader struct{ on bool }
func (s *switchLeader) IsLeader() bool { return s.on }
func TestAStandbyThatTakesOverWaitsForTheFleetBeforePlacing(t *testing.T) {
r, inv, disp, c := settlingHarness(t, testModel(1))
lead := &switchLeader{}
r.SetLeadership(lead)
addNode(inv, c, gpuNode("1", 8))
// A standby for longer than the window: its own start is long past.
r.Reconcile(t.Context())
c.advance(2 * DefaultConfig().SettleFor)
touchAt(inv, "1", c.now())
r.Reconcile(t.Context())
// It takes over. The agents have not told it what runs yet; placing now
// would start a second copy of what they are about to report.
lead.on = true
r.Reconcile(t.Context())
if n := len(disp.startedShards()); n != 0 {
t.Fatalf("placed %d shard(s) the moment it took over", n)
}
// Once the fleet has had its window, it places what is still missing.
c.advance(DefaultConfig().SettleFor)
touchAt(inv, "1", c.now())
r.Reconcile(t.Context())
if len(disp.startedShards()) == 0 {
t.Fatal("nothing placed after the window")
}
}
+87 -10
View File
@@ -270,6 +270,9 @@ type Reconciler struct {
restartQueue map[string][]string
// restartInPlace are the replicas asked to restart by stopping first.
restartInPlace map[string]bool
// restartPace is, per model, how many of its replicas may be restarting
// at once: one unless an operator said otherwise.
restartPace map[string]RestartPace
// restartRefused says, by replica, why a restart was given up rather
// than leave the model without it.
restartRefused map[string]string
@@ -304,6 +307,12 @@ type Reconciler struct {
startedAt time.Time
lastNodeCount int
steadySince time.Time
// stopped is the operators having stopped the service: every model is
// wanted at no replicas until they start it again.
stopped atomic.Bool
// stoodBy is whether the last pass found this process a standby: one
// that then takes over settles again, as at start.
stoodBy bool
}
// New builds a reconciler.
@@ -323,6 +332,7 @@ func New(cfg Config, cat *catalog.Live, inv *inventory.Store, disp Dispatcher, l
wanted: map[string]time.Time{},
restartQueue: map[string][]string{},
restartInPlace: map[string]bool{},
restartPace: map[string]RestartPace{},
restartRefused: map[string]string{},
parked: map[string]parkRecord{},
extra: map[string]int{},
@@ -408,8 +418,22 @@ func (r *Reconciler) Reconcile(ctx context.Context) {
// Unset means leader, so a single control plane and every test behave
// exactly as they did before leadership existed.
if r.leader != nil && !r.leader.IsLeader() {
r.stoodBy = true
return
}
// Taking over from another control plane is a start as far as the fleet
// is concerned: a standby refuses agents, so it knows none of what runs,
// and placing before they have reconnected and said starts a second
// copy of every replica — then drains the healthy one it finds next.
// Measured on the cluster: a failover replaced the guardrails' judge and
// a model's replica within twenty seconds, for nothing.
// From the moment it leads, not from when the process started: a
// standby a minute old had its window run out while it was refusing the
// very agents the window waits for.
if r.stoodBy {
r.stoodBy = false
r.resettle()
}
ctx, p := beginPass(ctx)
defer p.end()
r.expireNodes(ctx)
@@ -463,6 +487,25 @@ func (r *Reconciler) stopReplicasOfRemovedModels(ctx context.Context) {
}
}
// SetStopped stops every model, or lets each run as its catalog entry
// asks again, from the next pass.
func (r *Reconciler) SetStopped(stopped bool) {
if r.stopped.Swap(stopped) != stopped {
r.Notify()
}
}
// Stopped is whether the operators have stopped the service.
func (r *Reconciler) Stopped() bool { return r.stopped.Load() }
// resettle opens the settling window again from now, for a control plane
// that has just become the leader.
func (r *Reconciler) resettle() {
// Like the rest of the window, touched only by Reconcile's own pass.
r.startedAt, r.lastNodeCount, r.steadySince = r.now(), -1, r.now()
r.log.Info("leading now: placement waits for the fleet to report what runs", "for up to", r.cfg.SettleFor)
}
// fleetHasSettled reports whether placement may run yet.
//
// Two ways out, because either alone is wrong. Waiting for the window to expire
@@ -775,19 +818,37 @@ func (r *Reconciler) reconcileModel(ctx context.Context, m catalog.Model) {
drainingIDs = append(drainingIDs, rep.ID)
}
}
// The next restart an operator queued, once the last one is done.
// The next restarts an operator queued, as many at once as the pace
// allows: one by default, each begun once the last is done.
restarting := ""
if len(retiringIDs) == 0 {
if id, ok := r.nextRestartLocked(m.Name); ok {
r.replicas[id].RetiringAt = r.now()
retiringIDs = append(retiringIDs, id)
liveIDs = slices.DeleteFunc(liveIDs, func(x string) bool { return x == id })
readyLive--
restarting = id
r.log.Info("restarting a replica: placing its replacement first",
"replica", id, "model", m.Name)
pace := r.restartPace[m.Name]
inPlaceNow := 0
for _, id := range retiringIDs {
if r.restartInPlace[id] {
inPlaceNow++
}
}
for len(retiringIDs) < pace.width() {
id, ok := r.nextRestartLocked(m.Name)
if !ok {
break
}
// Up to MaxUnavailable of them stop first; the rest are surged.
if inPlaceNow < pace.MaxUnavailable {
r.restartInPlace[id] = true
inPlaceNow++
}
r.replicas[id].RetiringAt = r.now()
retiringIDs = append(retiringIDs, id)
liveIDs = slices.DeleteFunc(liveIDs, func(x string) bool { return x == id })
readyLive--
restarting = id
r.log.Info("restarting a replica", "replica", id, "model", m.Name,
"in_place", r.restartInPlace[id], "at_once", pace.width())
}
if _, queued := r.restartQueue[m.Name]; !queued && len(retiringIDs) == 0 {
delete(r.restartPace, m.Name)
}
blockedUntil := r.nextAttempt[m.Name]
park, isParked := r.parked[m.Name]
if isParked && park.fp != modelFingerprint(m) {
@@ -1246,6 +1307,22 @@ func (r *Reconciler) OnFetchProgress(replicaID, shardID, source string, done, to
}
}
// OnShardCompute records where a shard's engine said it put the model.
func (r *Reconciler) OnShardCompute(replicaID, shardID string, c Compute) {
r.mu.Lock()
defer r.mu.Unlock()
rep, ok := r.replicas[replicaID]
if !ok {
return
}
for i := range rep.Shards {
if rep.Shards[i].ID == shardID {
cc := c
rep.Shards[i].Compute = &cc
}
}
}
// OnShardStatus records a status report from an agent.
func (r *Reconciler) OnShardStatus(replicaID, shardID string, st supervisor.State, errMsg string) {
r.markShard(replicaID, shardID, st, errMsg)
@@ -2063,3 +2063,140 @@ func TestLoadAddsReplicasUpToMaxAndQuietTakesThemBack(t *testing.T) {
t.Errorf("%d replicas after a long quiet spell; min is 1", n)
}
}
func TestReplicasRestartSeveralAtOnceWhenAsked(t *testing.T) {
h := newHarness(t, testModel(3), gpuNode("1", 8), gpuNode("2", 8), gpuNode("3", 8), gpuNode("4", 8))
for i := 0; i < 3; i++ {
h.r.Reconcile(t.Context())
h.markReady()
}
var ids []string
for _, rep := range h.r.ReadyReplicas() {
ids = append(ids, rep.ID)
}
if err := h.r.RestartPaced(RestartPace{MaxSurge: 2}, false, ids...); err != nil {
t.Fatal(err)
}
h.r.Reconcile(t.Context())
replacing, queued := 0, 0
for _, id := range ids {
switch h.r.RestartState(id) {
case "replacing":
replacing++
case "queued":
queued++
}
}
if replacing != 2 || queued != 1 {
t.Errorf("max surge 2: %d replacing, %d queued", replacing, queued)
}
}
func TestMaxUnavailableStopsThatManyFirst(t *testing.T) {
h := newHarness(t, testModel(2), gpuNode("1", 8), gpuNode("2", 8), gpuNode("3", 8))
for i := 0; i < 3; i++ {
h.r.Reconcile(t.Context())
h.markReady()
}
var ids []string
for _, rep := range h.r.ReadyReplicas() {
ids = append(ids, rep.ID)
}
if err := h.r.RestartPaced(RestartPace{MaxSurge: 1, MaxUnavailable: 1}, false, ids...); err != nil {
t.Fatal(err)
}
h.r.Reconcile(t.Context())
stopped, beside := 0, 0
for _, id := range ids {
rep, ok := replicaByID(h, id)
switch {
case !ok || rep.State != Ready:
stopped++
case h.r.RestartState(id) == "replacing":
beside++
}
}
if stopped != 1 || beside != 1 {
t.Errorf("one stopped first and one replaced beside itself: %d stopped, %d beside", stopped, beside)
}
}
func TestAnAdoptedShardKeepsWhatItComputesOn(t *testing.T) {
// A control plane that took over, or restarted, learns where a replica
// computes from what its agent kept, not from a READY it never saw.
h := newHarness(t, testModel(1), gpuNode("1", 8))
a := adopted("llama-99", "llama-99-r0", "1", 0, []int{0, 1, 2, 3})
a.Compute = &Compute{Backend: "CUDA", Devices: []string{"CUDA0: H100"}, Offloaded: 81, Total: 81}
h.r.Adopt("1", []AdoptedShard{a})
reps := h.r.Replicas()
if len(reps) != 1 || reps[0].Shards[0].Compute == nil || reps[0].Shards[0].Compute.Offloaded != 81 {
t.Fatalf("%+v", reps)
}
}
func TestStoppingTheServiceTakesEveryModelDownAndStartingBringsItBack(t *testing.T) {
h := newHarness(t, testModel(1), gpuNode("1", 8))
h.r.Reconcile(t.Context())
h.markReady()
if len(h.r.ReadyReplicas()) != 1 {
t.Fatal("nothing ready to stop")
}
h.r.SetStopped(true)
h.r.Reconcile(t.Context())
for _, rep := range h.r.Replicas() {
if rep.State == Ready {
t.Fatalf("stopped, a replica still serves: %+v", rep)
}
}
h.r.SetStopped(false)
for range 3 {
h.r.Reconcile(t.Context())
h.markReady()
}
if len(h.r.ReadyReplicas()) != 1 {
t.Errorf("started again, %d ready", len(h.r.ReadyReplicas()))
}
}
func TestTheRoomAnInPlaceRestartFreesIsItsReplacements(t *testing.T) {
// Seen on the reference cluster: a model restarted in place on the only
// GPU, a judge waiting for that card, and the judge placed on it in the
// same pass, before the replacement — the model was gone until someone
// stopped the judge by hand.
for _, waitingFirst := range []bool{false, true} {
m := testModel(1)
waiting := testModel(1)
waiting.Name, waiting.Source = "judge", "/models/judge"
models := []catalog.Model{m, waiting}
if waitingFirst {
models = []catalog.Model{waiting, m}
}
h := newHarness(t, m, gpuNode("1", 4))
h.r.Reconcile(t.Context())
h.markReady()
old := h.r.ReadyReplicas()[0].ID
h.setModels(t, models...)
h.r.Reconcile(t.Context()) // the judge finds no room
if err := h.r.Restart(old); err != nil {
t.Fatal(err)
}
h.r.Reconcile(t.Context())
var ofModel, ofWaiting int
for _, rep := range h.r.Replicas() {
if rep.ID == old || !rep.State.Counts() {
continue
}
switch rep.Model {
case m.Name:
ofModel++
case waiting.Name:
ofWaiting++
}
}
if ofModel != 1 || ofWaiting != 0 {
t.Errorf("waiting first=%v: the freed GPUs went to %d replacement(s) and %d of the waiting model",
waitingFirst, ofModel, ofWaiting)
}
}
}
+14
View File
@@ -72,6 +72,20 @@ type Shard struct {
// shard reaches any other state, so a finished download does not sit in
// the listing looking like one still running.
Fetch *FetchProgress
// Compute is where the engine said it put the model as it became ready:
// nil when it did not say.
Compute *Compute
}
// Compute is where an engine runs its model, by its own account.
type Compute struct {
// Backend is CUDA, Vulkan, Metal, ROCm, GPU or CPU.
Backend string
// Devices it named: "CUDA0: Quadro RTX 6000 (7.5)".
Devices []string
// Offloaded of Total layers went to the devices; 0 and 0 when unsaid.
Offloaded, Total int
}
// FetchProgress is how far a shard's weights have got.
+72 -2
View File
@@ -73,11 +73,45 @@ func (r *Reconciler) retire(ctx context.Context, m catalog.Model, ids []string,
}
}
// stopForRestart drains a replica and stops it at once, for its replacement
// to take its place.
// stopForRestart drains a replica and stops it at once, and places its
// replacement in what it freed.
//
// At once, not on the next pass: the pass goes on to the other models, and
// one waiting for room took the freed cards first. Seen on the reference
// cluster with a judge waiting for the only GPU: the model restarted there
// lost its card to the judge.
func (r *Reconciler) stopForRestart(ctx context.Context, id, why string) {
r.mu.Lock()
rep, ok := r.replicas[id]
model := ""
if ok {
model = rep.Model
}
r.mu.Unlock()
r.drainForRestart(ctx, id, why)
r.stopReplica(ctx, id, why)
m, found := r.cat.Load().Resolve(model)
if !ok || !found || m.Name != model {
return
}
r.mu.Lock()
var avoid []string
live := 0
for _, o := range r.replicas {
// As reconcileModel counts: one being retired is not one it has.
if o.Model == model && o.State.Counts() && o.RetiringAt.IsZero() {
live++
avoid = append(avoid, o.NodeIDs()...)
}
}
r.mu.Unlock()
if live >= r.desiredFor(m) {
return
}
if outcome := r.scaleUp(ctx, m, avoid); outcome != placed {
r.log.Warn("the replacement of a replica restarted in place was not placed in its room",
"replica", id, "model", model, "outcome", outcome.String())
}
}
// refuseRestart gives up a replica's restart, leaving it serving, and keeps
@@ -127,6 +161,42 @@ func (r *Reconciler) nextRestartLocked(model string) (string, bool) {
return "", false
}
// RestartPace is how many of a model's replicas restart at once.
type RestartPace struct {
// MaxSurge are replaced beside themselves at once: their replacements
// placed first, then they stop. Zero is one.
MaxSurge int
// MaxUnavailable are stopped first at once, their replacements starting
// in their place: an outage for each while it loads, needing no room.
MaxUnavailable int
}
// width is how many may be restarting at once.
func (p RestartPace) width() int {
surge := p.MaxSurge
if surge <= 0 && p.MaxUnavailable <= 0 {
surge = 1
}
return surge + max(p.MaxUnavailable, 0)
}
// RestartPaced queues replicas like Restart (or RestartInPlace), as many of
// each model's at once as pace says.
func (r *Reconciler) RestartPaced(pace RestartPace, inPlace bool, ids ...string) error {
if err := r.restart(inPlace, ids); err != nil {
return err
}
r.mu.Lock()
for _, id := range ids {
if rep, ok := r.replicas[id]; ok {
r.restartPace[rep.Model] = pace
}
}
r.mu.Unlock()
r.Notify()
return nil
}
// ErrNoSuchReplica means no ready replica has that id.
var ErrNoSuchReplica = errors.New("no ready replica with that id")
+18 -2
View File
@@ -121,15 +121,17 @@ var Reference = []Block{
{Key: "enabled", Type: "bool", Doc: "Enabled keeps llmgw's internal ports — engines, node exporter, the\nagents' stream, the mirror, the store — to the cluster's networks on\nevery machine. False removes llmgw's nftables table everywhere, for a\nsite whose own firewall does that job; the rules are kept, and true\nputs them back. Changed in the cluster's settings, it is in force on\nevery node and control plane within seconds.", Nested: ""},
}},
{Package: "catalog", Name: "GuardModel", Title: "GuardModel", Doc: "", Fields: []Field{
{Key: "format", Type: "string", Doc: "Format is llamaguard3 (Meta's Llama Guard 3: \"safe\", or \"unsafe\" and\nthe categories), shieldgemma (Google's ShieldGemma: Yes or No), or\njson ({\"safe\": false, \"categories\": [\"...\"]}).", Nested: ""},
{Key: "format", Type: "string", Doc: "Format is qwen3guard (Qwen3Guard-Gen: \"Safety: Safe\", \"Unsafe\" or\n\"Controversial\", and the categories), llamaguard3 (Meta's Llama Guard\n3: \"safe\", or \"unsafe\" and the categories), shieldgemma (Google's\nShieldGemma: Yes or No), or json ({\"safe\": false, \"categories\":\n[\"...\"]}).", Nested: ""},
{Key: "categories", Type: "[]string", Doc: "Categories the model is asked about, for shieldgemma — each a policy\nsentence — and the names reported for llamaguard3's S1, S2, ….", Nested: ""},
}},
{Package: "guard", Name: "JudgePolicy", Title: "JudgePolicy", Doc: "", Fields: []Field{
{Key: "model", Type: "string", Doc: "Model is a catalog model with role: guard.", Nested: ""},
{Key: "models", Type: "[]string", Doc: "Models are judges asked in turn, instead of Model: the first that\nanswers gives the verdict. A judge on a GPU first and one on CPUs\nafter it is the GPU's speed while it is there, and the CPUs' when it\nis not — busy, failed, or the node down.", Nested: ""},
{Key: "input", Type: "string", Doc: "Input and Output are what an unsafe verdict does: flag or block.\nEmpty does not ask the judge about that direction.", Nested: ""},
{Key: "output", Type: "string", Doc: "", Nested: ""},
{Key: "categories", Type: "[]string", Doc: "Categories, when set, are the only ones that count: Llama Guard's\ncodes (S1, S10) or the names the model reports.", Nested: ""},
{Key: "latency_budget", Type: "time.Duration", Doc: "LatencyBudget is how long the judge is waited for before the policy's\nfail applies. Zero is 250ms.", Nested: ""},
{Key: "controversial", Type: "string", Doc: "Controversial is what Qwen3Guard's third verdict counts as: safe\n(the default) or unsafe.", Nested: ""},
{Key: "latency_budget", Type: "time.Duration", Doc: "LatencyBudget is how long each judge is waited for before the next is\nasked, or, after the last, the policy's fail applies. Zero is 250ms.", Nested: ""},
}},
{Package: "config", Name: "KVIndex", Title: "KVIndex", Doc: "", Fields: []Field{
{Key: "tokenize_timeout", Type: "time.Duration", Doc: "TokenizeTimeout bounds the call that turns a request into token ids,\nmade to one of the model's replicas before the request is placed. Past\nit the request is routed as tier 1 would. Zero takes 250ms.", Nested: ""},
@@ -261,6 +263,7 @@ var Reference = []Block{
{Package: "catalog", Name: "Profile", Title: "Profile", Doc: "", Fields: []Field{
{Key: "name", Type: "string", Doc: "", Nested: ""},
{Key: "engine", Type: "string", Doc: "", Nested: ""},
{Key: "engine_version", Type: "string", Doc: "EngineVersion restricts the profile to nodes whose installed engine's\nversion starts with it: \"0.11\" takes vLLM 0.11.0 and 0.11.1, not\n0.10.2. Empty takes whatever version the node has.", Nested: ""},
{Key: "gpu_class", Type: "string", Doc: "", Nested: ""},
{Key: "tp", Type: "int", Doc: "", Nested: ""},
{Key: "pp", Type: "int", Doc: "", Nested: ""},
@@ -366,6 +369,8 @@ var Reference = []Block{
{Key: "guard", Type: "guard.Config", Doc: "Guard is the library of guardrail policies: what requests and answers\nare checked for, and what is done. Applied to every request through\nguard.default, and to parts of the tenancy tree by attaching them\n(llmgw ctl guard attach). Empty checks nothing.", Nested: "guard.Config"},
{Key: "secrets", Type: "Secrets", Doc: "Secrets is where secrets kept outside files are read from, and the key\nthe store's own secrets are sealed under.", Nested: "config.Secrets"},
{Key: "notify", Type: "notify.Config", Doc: "Notify sends what needs a person — a model parked, a node lost, a\nbudget spent, new weights published — to webhooks, signed, without a\nPrometheus and Alertmanager in between. Empty sends nothing.", Nested: "notify.Config"},
{Key: "mail", Type: "mail.Config", Doc: "Mail is where the gateway's few emails go out from: a link to set a\nforgotten password. Unset, none is sent and people ask an operator.", Nested: "mail.Config"},
{Key: "serving", Type: "ServingSettings", Doc: "Serving stops and starts the whole cluster's service (llmgw stop, llmgw\nstart).", Nested: "config.ServingSettings"},
{Key: "firewall", Type: "FirewallSettings", Doc: "Firewall turns llmgw's firewall on every machine on or off.", Nested: "config.FirewallSettings"},
{Key: "firewall_networks", Type: "[]string", Doc: "FirewallNetworks are networks, besides those each machine reaches the\ncluster on, that llmgw's internal ports answer: a monitoring host on\nanother subnet, nodes behind a router. Written into every machine's\nfirewall by `server init`, `node add` and `server add`.", Nested: ""},
{Key: "backup_recipients", Type: "[]string", Doc: "BackupRecipients encrypt every archive `llmgw server backup` writes, to\nwhoever holds one of their keys: age recipients (age1…) or SSH public\nkeys (ssh-ed25519 …, ssh-rsa …), as `age` takes them. An archive holds\nthe certificate authority's key and every secret in the store, and is\ncopied off the machine; encrypted, the copy is safe wherever it lands.\nEmpty writes archives unencrypted, readable by root only.", Nested: ""},
@@ -379,6 +384,9 @@ var Reference = []Block{
{Key: "federation", Type: "Federation", Doc: "Federation is how this cluster takes part in federation with other\nllmgw clusters. The peers themselves are added at runtime, with\n`llmgw ctl federation add`, and kept in the store.", Nested: "config.Federation"},
{Key: "endpoints", Type: "[]Endpoint", Doc: "Endpoints is the phase-1 static replica list. It is only consulted when\nno model declares a profile, which is what makes a router-only smoke test\nagainst sim-engines possible without a control plane.", Nested: ""},
}},
{Package: "config", Name: "ServingSettings", Title: "ServingSettings", Doc: "", Fields: []Field{
{Key: "stopped", Type: "bool", Doc: "Stopped takes every model down and answers every request for one with\n503 (system_stopped), leaving the control planes, agents and mirror\nrunning and every model's definition as it is: llmgw stop sets it,\nllmgw start clears it, and the models come back as their catalog\nentries ask. In force on every control plane within seconds.", Nested: ""},
}},
{Package: "engines", Name: "SimConfig", Title: "SimConfig", Doc: "", Fields: []Field{
{Key: "bin", Type: "string", Doc: "Bin is the llmgw binary itself; the engine is one of its subcommands.\nEmpty means whatever started this agent, which is right almost always.", Nested: ""},
}},
@@ -484,9 +492,17 @@ var Reference = []Block{
{Key: "sim", Type: "SimConfig", Doc: "", Nested: "engines.SimConfig"},
}},
{Package: "guard", Name: "Config", Title: "guard.Config", Doc: "", Fields: []Field{
{Key: "enabled", Type: "*bool", Doc: "Enabled turns every policy on or off at once: false checks nothing,\nwhatever is defined or attached. Unset is on.", Nested: ""},
{Key: "default", Type: "[]string", Doc: "", Nested: ""},
{Key: "policies", Type: "map[string]Policy", Doc: "", Nested: ""},
}},
{Package: "mail", Name: "Config", Title: "mail.Config", Doc: "", Fields: []Field{
{Key: "smtp", Type: "string", Doc: "SMTP is the relay, host:port: 587 with STARTTLS, 465 with TLS from the\nstart, 25 as the relay allows. Empty sends nothing.", Nested: ""},
{Key: "from", Type: "string", Doc: "From is the sender, llmgw@example.com or \"llmgw <llmgw@example.com>\".", Nested: ""},
{Key: "username", Type: "string", Doc: "Username and PasswordFile log in to the relay, when it wants that.\nThe password is a secret reference: a file, env:NAME or vault:path.", Nested: ""},
{Key: "password_file", Type: "string", Doc: "", Nested: ""},
{Key: "base_url", Type: "string", Doc: "BaseURL is the gateway's public address, which links in mail point\nat: https://llm.example.com. Never taken from a request, whose Host a\nsender chooses and could point a reset link at their own site.", Nested: ""},
}},
{Package: "notify", Name: "Config", Title: "notify.Config", Doc: "", Fields: []Field{
{Key: "webhooks", Type: "[]Webhook", Doc: "", Nested: ""},
{Key: "host", Type: "string", Doc: "Host names the gateway in messages; empty is this machine's name.", Nested: ""},
+2
View File
@@ -31,6 +31,8 @@ var Sources = []string{
"internal/guard/judge.go",
"internal/config/oidc.go",
"internal/config/firewall.go",
"internal/config/serving.go",
"internal/mail/mail.go",
"internal/notify/notify.go",
"internal/secrets/secrets.go",
"internal/obs/trace.go",
+42
View File
@@ -819,3 +819,45 @@ func sortedKeys[V any](m map[string]V) []string {
sort.Strings(out)
return out
}
// MultiNode is whether a finding is about the fabrics replicas spanning
// nodes use, rather than about the node itself.
func (f Finding) MultiNode() bool { return f.Area == "fabric" }
// Readiness is a node's two readinesses, kept apart: whether it can join and
// serve replicas of its own, and whether it can also take part in replicas
// spanning nodes over a fabric. The second never keeps a node out unless a
// fabric was declared for it, whose problems are then the node's.
type Readiness struct {
// Node is "ready", or how many problems keep it out.
Node string `json:"node"`
// MultiNode is "ready", "not used" (no fabric adapter), or "not ready:"
// and the first reason.
MultiNode string `json:"multi_node"`
}
// ReadinessOf sums findings up as the two readinesses.
func ReadinessOf(fs []Finding) Readiness {
r := Readiness{Node: "ready", MultiNode: "not used"}
fails := 0
for _, f := range fs {
if f.Level == Fail {
fails++
}
}
if fails > 0 {
r.Node = fmt.Sprintf("%d problem(s) keep it out", fails)
}
for _, f := range fs {
if !f.MultiNode() {
continue
}
if r.MultiNode == "not used" {
r.MultiNode = "ready"
}
if (f.Level == Fail || f.Level == Warn) && !strings.HasPrefix(r.MultiNode, "not ready") {
r.MultiNode = "not ready: " + f.Message
}
}
return r
}
+17
View File
@@ -259,3 +259,20 @@ func TestAMissingFirewallIsSaidOnASetUpNode(t *testing.T) {
}
}
}
func TestTheNodeAndItsFabricsAreReadySeparately(t *testing.T) {
r := ReadinessOf([]Finding{
{Level: OK, Area: "gpu", Message: "2 GPUs"},
{Level: Warn, Area: "fabric", Message: "mlx5_1 is not active"},
})
if r.Node != "ready" || r.MultiNode != "not ready: mlx5_1 is not active" {
t.Errorf("%+v", r)
}
r = ReadinessOf([]Finding{{Level: Fail, Area: "gpu", Message: "no driver"}})
if !strings.Contains(r.Node, "1 problem") || r.MultiNode != "not used" {
t.Errorf("%+v", r)
}
if r := ReadinessOf([]Finding{{Level: OK, Area: "fabric", Message: "mlx5_0 active"}}); r.MultiNode != "ready" {
t.Errorf("%+v", r)
}
}
+30 -3
View File
@@ -156,6 +156,10 @@ func (g *Gate) Invalidate() { g.reload(context.Background()) }
// from.
func (g *Gate) Policy(s Subject) (Policy, string) {
g.mu.Lock()
if !g.cfg.On() {
g.mu.Unlock()
return Policy{}, ""
}
if !g.loading && g.store != nil && g.now().Sub(g.loadedAt) > attachmentsTTL {
g.loading = true
go g.reload(context.WithoutCancel(context.Background()))
@@ -328,7 +332,7 @@ func (g *Gate) Input(ctx context.Context, s Subject, body []byte) (Verdict, erro
done := func(f finding) Verdict {
v.Action, v.Category, v.Rule = f.action, f.category, f.rule
if f.action == Block {
v.Message = fmt.Sprintf("this request was refused by the gateway's policy (%s)", f.category)
v.Message = fmt.Sprintf("this request was refused by the gateway's policy (%s)", refusedFor(f))
v.Body = nil
}
v.Took = g.now().Sub(start)
@@ -406,8 +410,8 @@ func (g *Gate) Input(ctx context.Context, s Subject, body []byte) (Verdict, erro
if jp := p.Judge; jp != nil && jp.Input != "" && user != "" {
res, err := g.judger.ask(ctx, jp, user, "")
if err != nil {
g.log.Warn("the judge gave no verdict on a request", "judge", jp.Model, "err", err)
g.judgeFailed(jp.Model, err)
g.log.Warn("the judge gave no verdict on a request", "judge", jp.Name(), "err", err)
g.noJudger(jp, err)
}
if err != nil && p.Fail == "closed" && !v.Shadow {
v.Took = g.now().Sub(start)
@@ -451,6 +455,18 @@ func (g *Gate) Input(ctx context.Context, s Subject, body []byte) (Verdict, erro
// the gate serves.
func (g *Gate) SetJudgeAlarm(f func(model string, err error)) { g.alarm = f }
// noJudger records, against each judge a policy asks, that there was no way
// to ask any: a gate built without SetJudge. With one, each judge's failure
// is recorded as it is asked.
func (g *Gate) noJudger(jp *JudgePolicy, err error) {
if g.judger != nil {
return
}
for _, m := range jp.Judges() {
g.judgeFailed(m, err)
}
}
func (g *Gate) judgeFailed(model string, err error) {
g.errMu.Lock()
if g.lastErr == nil {
@@ -524,3 +540,14 @@ func toolAllowed(t Tools, name string) bool {
}
return false
}
// refusedFor is what a refusal names: the category, and for a judge the
// hazard it saw — "judge: S6,Specialized Advice" — so whoever is refused
// can say why, and a judge refusing ordinary questions can be told apart.
// Never the text.
func refusedFor(f finding) string {
if f.category == "judge" && f.rule != "" && f.rule != "unavailable" {
return "judge: " + f.rule
}
return f.category
}
+31
View File
@@ -278,3 +278,34 @@ func TestCapabilitiesAndStructuredAnswersWhenAskedFor(t *testing.T) {
}
}
}
func TestTheGuardrailsTurnOffAndOnAsAWhole(t *testing.T) {
off := false
cfg := lib(map[string]Policy{"base": {Input: Rules{PII: map[string]string{"secret": "block"}}}}, "base")
cfg.Enabled = &off
g := gate(t, cfg)
secret := chat("user", "my key is sk-proj-abcdefghijklmnopqrstuvwx1234")
if v, err := g.Input(context.Background(), Subject{}, secret); err != nil || v.Action != "" {
t.Errorf("off, a secret was acted on: %+v %v", v, err)
}
on := true
cfg.Enabled = &on
if err := g.Replace(cfg); err != nil {
t.Fatal(err)
}
if v, _ := g.Input(context.Background(), Subject{}, secret); v.Action != Block {
t.Errorf("on again, a secret went through: %+v", v)
}
if !(Config{}).On() {
t.Error("unset is not on")
}
}
func TestAJudgesRefusalNamesTheHazard(t *testing.T) {
if got := refusedFor(finding{category: "judge", rule: "S6,Specialized Advice"}); got != "judge: S6,Specialized Advice" {
t.Errorf("%q", got)
}
if got := refusedFor(finding{category: "secret", rule: "pii:secret"}); got != "secret" {
t.Errorf("%q", got)
}
}
+328 -31
View File
@@ -10,6 +10,7 @@ import (
"strings"
"sync"
"time"
"unicode/utf8"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard/detect"
)
@@ -19,6 +20,11 @@ import (
type JudgePolicy struct {
// Model is a catalog model with role: guard.
Model string `koanf:"model"`
// 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.
Models []string `koanf:"models"`
// Input and Output are what an unsafe verdict does: flag or block.
// Empty does not ask the judge about that direction.
Input string `koanf:"input"`
@@ -26,11 +32,31 @@ type JudgePolicy struct {
// Categories, when set, are the only ones that count: Llama Guard's
// codes (S1, S10) or the names the model reports.
Categories []string `koanf:"categories"`
// LatencyBudget is how long the judge is waited for before the policy's
// fail applies. Zero is 250ms.
// Controversial is what Qwen3Guard's third verdict counts as: safe
// (the default) or unsafe.
Controversial string `koanf:"controversial"`
// 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.
LatencyBudget time.Duration `koanf:"latency_budget"`
}
// Judges are the judge models asked, in the order they are asked.
func (j *JudgePolicy) Judges() []string {
if j == nil {
return nil
}
if len(j.Models) > 0 {
return j.Models
}
if j.Model != "" {
return []string{j.Model}
}
return nil
}
// Name is the judges as one name, for logs and errors: "a, else b".
func (j *JudgePolicy) Name() string { return strings.Join(j.Judges(), ", else ") }
// DefaultJudgeBudget is how long a judge is waited for when a policy does
// not say.
const DefaultJudgeBudget = 250 * time.Millisecond
@@ -43,8 +69,26 @@ func (j *JudgePolicy) budget() time.Duration {
}
func (j *JudgePolicy) validate() error {
if j.Model == "" {
return errors.New("judge.model is required")
switch {
case j.Model == "" && len(j.Models) == 0:
return errors.New("judge.model (or judge.models, judges asked in turn) is required")
case j.Model != "" && len(j.Models) > 0:
return errors.New("judge: give model or models, not both")
}
seen := map[string]bool{}
for _, m := range j.Models {
if strings.TrimSpace(m) == "" {
return errors.New("judge.models has an empty name")
}
if seen[m] {
return fmt.Errorf("judge.models names %s twice", m)
}
seen[m] = true
}
switch j.Controversial {
case "", "safe", "unsafe":
default:
return fmt.Errorf("judge.controversial is %q: safe or unsafe", j.Controversial)
}
for k, a := range map[string]string{"input": j.Input, "output": j.Output} {
switch a {
@@ -59,8 +103,8 @@ func (j *JudgePolicy) validate() error {
return nil
}
// mergeJudge keeps the first judge model asked for, the strictest actions,
// every category and the shortest wait.
// mergeJudge keeps the first judges asked for, the strictest actions, every
// category and the shortest wait.
func mergeJudge(a, b *JudgePolicy) *JudgePolicy {
if b == nil {
return a
@@ -68,11 +112,14 @@ func mergeJudge(a, b *JudgePolicy) *JudgePolicy {
if a == nil {
c := *b
c.Categories = slices.Clone(b.Categories)
c.Models = slices.Clone(b.Models)
return &c
}
out := *a
out.Models = slices.Clone(a.Models)
out.Input = stricterOf(a.Input, b.Input, []string{"", "flag", "block"})
out.Output = stricterOf(a.Output, b.Output, []string{"", "flag", "block"})
out.Controversial = stricterOf(a.Controversial, b.Controversial, []string{"", "safe", "unsafe"})
out.Categories = union(slices.Clone(a.Categories), b.Categories)
if b.LatencyBudget > 0 && (a.LatencyBudget == 0 || b.LatencyBudget < a.LatencyBudget) {
out.LatencyBudget = b.LatencyBudget
@@ -91,8 +138,13 @@ type ModelFormat func(model string) (format string, categories []string, ok bool
// judgment is what a judge said.
type judgment struct {
unsafe bool
categories []string
unsafe bool
// controversial is Qwen3Guard's middle verdict, which the policy says
// how to count.
controversial bool
categories []string
// by is the judge that said it.
by string
}
// judge asks a judge model about text, with a cache of recent answers.
@@ -100,6 +152,9 @@ type judge struct {
call Caller
format ModelFormat
m *gateMetrics
// failed is told of each judge that gave no verdict, whether or not
// another after it did.
failed func(model string, err error)
mu sync.Mutex
cache map[[32]byte]cached
@@ -117,7 +172,7 @@ const (
// SetJudge lets policies with a judge: section ask a model on the cluster.
func (g *Gate) SetJudge(call Caller, format ModelFormat) {
g.judger = &judge{call: call, format: format, m: g.m, cache: map[[32]byte]cached{}}
g.judger = &judge{call: call, format: format, m: g.m, failed: g.judgeFailed, cache: map[[32]byte]cached{}}
}
// errNoJudge is a policy naming a judge the gateway cannot ask, and
@@ -127,36 +182,57 @@ var (
errJudgeFormat = errors.New("the judge's answer could not be read")
)
// ask has the judge read conversation turns — the user's, and the
// assistant's answer when judging output.
func (j *judge) ask(ctx context.Context, jp *JudgePolicy, user, answer string) (res judgment, err error) {
// ask has the judges read conversation turns — the user's, and the
// assistant's answer when judging output — each in turn until one answers.
// The error is the last judge's, when none did.
func (j *judge) ask(ctx context.Context, jp *JudgePolicy, user, answer string) (judgment, error) {
if j == nil {
return judgment{}, errNoJudge
}
start := time.Now()
cachedHit := false
defer func() {
if !cachedHit && j.m != nil {
j.m.judged(jp.Model, time.Since(start), err)
}
}()
format, names, ok := j.format(jp.Model)
if !ok {
return judgment{}, fmt.Errorf("%w: %q is not a model with role: guard", errNoJudge, jp.Model)
}
key := sha256.Sum256([]byte(jp.Model + "\x00" + detect.Normalize(user) + "\x00" + detect.Normalize(answer)))
models := jp.Judges()
key := sha256.Sum256([]byte(strings.Join(models, "\x01") + "\x00" + detect.Normalize(user) + "\x00" + detect.Normalize(answer)))
j.mu.Lock()
if c, ok := j.cache[key]; ok && time.Since(c.at) < judgeCacheTTL {
j.mu.Unlock()
cachedHit = true
return c.j, nil
}
j.mu.Unlock()
err := errNoJudge
for _, model := range models {
var res judgment
res, err = j.askOne(ctx, jp, model, user, answer)
if err == nil {
j.remember(key, res)
return res, nil
}
if j.failed != nil {
j.failed(model, err)
}
if ctx.Err() != nil {
// The request itself is gone: no judge after this one is wanted.
break
}
}
return judgment{}, err
}
// askOne asks one judge, waiting the policy's budget for it.
func (j *judge) askOne(ctx context.Context, jp *JudgePolicy, model, user, answer string) (res judgment, err error) {
start := time.Now()
defer func() {
if j.m != nil {
j.m.judged(model, time.Since(start), err)
}
}()
format, names, ok := j.format(model)
if !ok {
return judgment{}, fmt.Errorf("%w: %q is not a model with role: guard", errNoJudge, model)
}
ctx, cancel := context.WithTimeout(ctx, jp.budget())
defer cancel()
path, body := judgeRequest(jp.Model, format, names, user, answer)
out, err := j.call(ctx, jp.Model, path, body)
path, body := judgeRequest(model, format, names, user, answer)
out, err := j.call(ctx, model, path, body)
if err != nil {
return judgment{}, err
}
@@ -164,6 +240,11 @@ func (j *judge) ask(ctx context.Context, jp *JudgePolicy, user, answer string) (
if err != nil {
return judgment{}, err
}
res.by = model
return res, nil
}
func (j *judge) remember(key [32]byte, res judgment) {
j.mu.Lock()
if len(j.cache) >= judgeCacheSize {
for k, c := range j.cache {
@@ -174,12 +255,11 @@ func (j *judge) ask(ctx context.Context, jp *JudgePolicy, user, answer string) (
}
j.cache[key] = cached{j: res, at: time.Now()}
j.mu.Unlock()
return res, nil
}
// counts reports whether a judgment is one the policy acts on.
func (jp *JudgePolicy) counts(res judgment) (bool, string) {
if !res.unsafe {
if !res.unsafe && !(res.controversial && jp.Controversial == "unsafe") {
return false, ""
}
if len(jp.Categories) == 0 {
@@ -228,13 +308,116 @@ func llamaGuard3Answer(categories []string, user, answer string) string {
return b.String()
}
// qwen3GuardCategories are Qwen3Guard's own, as it names them in a verdict.
// Jailbreak is judged in a user's message only.
var qwen3GuardCategories = []string{"Violent", "Non-violent Illegal Acts", "Sexual Content or Sexual Acts", "PII",
"Suicide & Self-Harm", "Unethical Acts", "Politically Sensitive Topics", "Copyright Violation", "Jailbreak"}
// qwen3Guard is Qwen3Guard-Gen's prompt, as its chat template writes it,
// for a user's message or, with answer, the assistant's reply to it. Written
// out for the reason llamaGuard3Answer is, and for both directions so that a
// judge served without --jinja reads the same words.
func qwen3Guard(user, answer string) string {
who, task, policy, cats, refusal, last := "USER's query", "", "", qwen3GuardCategories, "", ""
if answer == "" {
task = "1) Evaluate whether THE LAST USER's query belongs to safe or unsafe content based on our safety policy.\n" +
"2) Identify the category of unsafe content found.\n"
policy = "**Safety: Safe**: The USER's query does not contain any prohibited content or content that could lead to prohibited content.\n" +
"**Safety: Unsafe**: The USER's query contains prohibited content.\n" +
"**Safety: Controversial**: The USER's query does not explicitly contain prohibited content, but its intent, context, " +
"or potential responses could be misinterpreted, misused, or exploited to generate unsafe content under certain conditions.\n"
} else {
who = "ASSISTANT's response"
cats = cats[:len(cats)-1]
task = "1) Evaluate whether THE LAST ASSISTANT's response belongs to safe or unsafe content based on our safety policy.\n" +
"2) Identify the category of unsafe content found.\n" +
"3) Determine if THE LAST ASSISTANT's response is a refusal to the USER's query.\n"
policy = "**Safety: Safe**: The ASSISTANT's response does not contain any prohibited content or content that could lead to prohibited content.\n" +
"**Safety: Unsafe**: The ASSISTANT's response contains prohibited content.\n" +
"**Safety: Controversial**: The ASSISTANT's response is not explicitly harmful, but could become risky or offensive depending on context or audience.\n"
refusal = "<BEGIN REFUSAL POLICY>\n" +
"**Refusal: Yes**: The ASSISTANT refused to answer or did not comply with the USER's request.\n" +
"**Refusal: No**: The ASSISTANT directly answered or complied with the USER's request.\n" +
"<END REFUSAL POLICY>\n\n"
last = "\n - The third line must be one of: 'Refusal: Yes', 'Refusal: No'."
}
var b strings.Builder
b.WriteString("<|im_start|>user\n# Task:\n")
b.WriteString(task)
b.WriteString("\n<BEGIN SAFETY POLICY>\n")
b.WriteString(policy)
b.WriteString("<END SAFETY POLICY>\n\n<BEGIN UNSAFE CONTENT CATEGORIES>\n")
for _, c := range cats {
b.WriteString(c)
b.WriteString(".\n")
}
b.WriteString("<END UNSAFE CONTENT CATEGORIES>\n\n")
b.WriteString(refusal)
b.WriteString("<BEGIN CONVERSATION>\n\nUSER: ")
b.WriteString(user)
if answer != "" {
b.WriteString("\n\nASSISTANT: ")
b.WriteString(answer)
}
b.WriteString("\n\n<END CONVERSATION>\n\nProvide your safety assessment for ONLY THE LAST **")
b.WriteString(who)
b.WriteString("** in the above conversation:\n" +
" - The first line must be one of: 'Safety: Safe', 'Safety: Unsafe', 'Safety: Controversial'.\n" +
" - The second line should start with 'Categories:' followed by a list of any unsafe content categories, " +
"separated by commas. If the content is safe, use 'Categories: None'.")
b.WriteString(last)
b.WriteString("<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n")
return b.String()
}
// What of a text a judge reads: its start and its end, so a long one still
// fits the judge's context. A slot of 4096 tokens, the judges' own, holds
// the prompt and about 6000 characters of code or prose; a request an editor
// sent with a whole file pasted into it overflowed it, the judge answered
// nothing, and under fail: closed the request was refused.
const (
judgeHead = 1500
judgeTail = 4500
judgeAnswerHead = 1500
judgeAnswerTail = 3000
judgeAskedHead = 500
judgeAskedTail = 1000
)
// ends is s, or its first head and last tail bytes when it is longer, cut
// on rune boundaries.
func ends(s string, head, tail int) string {
if len(s) <= head+tail {
return s
}
h := head
for h > 0 && !utf8.RuneStart(s[h]) {
h--
}
t := len(s) - tail
for t < len(s) && !utf8.RuneStart(s[t]) {
t++
}
return s[:h] + "\n[…]\n" + s[t:]
}
// judgeRequest is the request a judge model is sent, and where.
func judgeRequest(model, format string, categories []string, user, answer string) (string, []byte) {
if answer == "" {
user = ends(user, judgeHead, judgeTail)
} else {
user, answer = ends(user, judgeAskedHead, judgeAskedTail), ends(answer, judgeAnswerHead, judgeAnswerTail)
}
if format == "llamaguard3" && answer != "" {
b, _ := json.Marshal(map[string]any{"model": model, "prompt": llamaGuard3Answer(categories, user, answer),
"max_tokens": 16, "temperature": 0, "stream": false})
return "/v1/completions", b
}
if format == "qwen3guard" {
b, _ := json.Marshal(map[string]any{"model": model, "prompt": qwen3Guard(user, answer),
"max_tokens": 32, "temperature": 0, "stream": false})
return "/v1/completions", b
}
var msgs []map[string]string
switch format {
case "llamaguard3":
@@ -294,6 +477,10 @@ func parseJudgment(format string, names []string, body []byte, answer string) (j
}
switch format {
case "llamaguard3":
if len(names) == 0 {
// Its own: S1 is Violent Crimes, S9 Indiscriminate Weapons.
names = llamaGuard3Categories
}
lines := strings.Split(text, "\n")
switch strings.ToLower(strings.TrimSpace(lines[0])) {
case "safe":
@@ -316,6 +503,38 @@ func parseJudgment(format string, names []string, body []byte, answer string) (j
}
return res, nil
}
case "qwen3guard":
// "Safety: Unsafe\nCategories: Violent, Jailbreak", and for an
// answer a third line, "Refusal: No", that says nothing of safety.
var res judgment
verdict := ""
for _, line := range strings.Split(text, "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
v = strings.TrimSpace(v)
switch strings.ToLower(strings.TrimSpace(k)) {
case "safety":
verdict = strings.ToLower(v)
case "categories":
for _, c := range strings.Split(v, ",") {
if c = strings.TrimSpace(c); c != "" && !strings.EqualFold(c, "none") {
res.categories = append(res.categories, c)
}
}
}
}
switch verdict {
case "safe":
return judgment{}, nil
case "unsafe":
res.unsafe = true
return res, nil
case "controversial":
res.controversial = true
return res, nil
}
case "shieldgemma":
l := strings.ToLower(text)
switch {
@@ -362,10 +581,88 @@ func Moderate(ctx context.Context, call Caller, model, format string, names []st
}
// DefaultCategories are the names a judge format reports its categories by
// when the catalog gives none: Llama Guard 3's S1 to S13.
// when the catalog gives none: Llama Guard 3's S1 to S13, Qwen3Guard's own.
func DefaultCategories(format string) []string {
if format == "llamaguard3" {
switch format {
case "llamaguard3":
return slices.Clone(llamaGuard3Categories)
case "qwen3guard":
return slices.Clone(qwen3GuardCategories)
}
return nil
}
// Formats are the ways a judge's answers are read.
var Formats = []string{"llamaguard3", "qwen3guard", "shieldgemma", "json"}
// KnownFormat is whether a judge's answers can be read in that format.
func KnownFormat(f string) bool { return slices.Contains(Formats, f) }
// FormatOfWeights is the format of the judge whose weights a source names —
// Llama Guard, Qwen3Guard, ShieldGemma — and false for weights that are no
// judge's.
func FormatOfWeights(source string) (string, bool) {
s := strings.ToLower(source)
switch {
case strings.Contains(s, "llama-guard"):
return "llamaguard3", true
case strings.Contains(s, "qwen3guard"):
return "qwen3guard", true
case strings.Contains(s, "shieldgemma"):
return "shieldgemma", true
}
return "", false
}
// JudgeAnswer is what a judge said of a text: its words as it gave them,
// and as the gateway reads them.
type JudgeAnswer struct {
Model string
Raw string
Unsafe bool
// Controversial is Qwen3Guard's middle verdict; a policy says whether
// it counts.
Controversial bool
Categories []string
Took time.Duration
}
// AskJudge has a judge read a text, past the cache, and says what it
// answered: for an operator finding out why ordinary requests are refused.
// With answer, the judge reads it as the assistant's reply to text.
func (g *Gate) AskJudge(ctx context.Context, model, text, answer string) (JudgeAnswer, error) {
out := JudgeAnswer{Model: model}
if g.judger == nil {
return out, errNoJudge
}
format, names, ok := g.judger.format(model)
if !ok {
return out, fmt.Errorf("%w: %q is not a model with role: guard", errNoJudge, model)
}
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
start := time.Now()
path, body := judgeRequest(model, format, names, text, answer)
reply, err := g.judger.call(ctx, model, path, body)
out.Took = time.Since(start)
if err != nil {
return out, err
}
var resp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Text string `json:"text"`
} `json:"choices"`
}
if json.Unmarshal(reply, &resp) == nil && len(resp.Choices) > 0 {
out.Raw = strings.TrimSpace(resp.Choices[0].Message.Content + resp.Choices[0].Text)
}
res, err := parseJudgment(format, names, reply, answer)
if err != nil {
return out, err
}
out.Unsafe, out.Controversial, out.Categories = res.unsafe, res.controversial, res.categories
return out, nil
}
+127 -1
View File
@@ -9,6 +9,7 @@ import (
"sync/atomic"
"testing"
"time"
"unicode/utf8"
"github.com/prometheus/client_golang/prometheus/testutil"
)
@@ -41,6 +42,11 @@ func (f *fakeJudge) call(ctx context.Context, _, _ string, body []byte) ([]byte,
answer = map[bool]string{true: "Yes", false: "No"}[unsafe]
case "json":
answer = map[bool]string{true: `{"safe": false, "categories": ["violence"]}`, false: `{"safe": true}`}[unsafe]
case "qwen3guard":
answer = map[bool]string{true: "Safety: Unsafe\nCategories: Violent", false: "Safety: Safe\nCategories: None"}[unsafe]
if strings.Contains(string(body), "a heist film") {
answer = "Safety: Controversial\nCategories: Non-violent Illegal Acts"
}
default:
answer = map[bool]string{true: "unsafe\nS1,S10", false: "safe"}[unsafe]
}
@@ -61,7 +67,7 @@ func judged(t *testing.T, p Policy, fj *fakeJudge) *Gate {
func TestAJudgeModelDecidesWhatPatternsCannot(t *testing.T) {
ctx := context.Background()
for _, format := range []string{"llamaguard3", "shieldgemma", "json"} {
for _, format := range Formats {
fj := &fakeJudge{format: format}
g := judged(t, Policy{Judge: &JudgePolicy{Model: "guard", Input: "block"}}, fj)
if v, err := g.Input(ctx, Subject{}, chat("user", "plan an attack")); err != nil || v.Action != Block || v.Category != "judge" {
@@ -191,3 +197,123 @@ func TestAnAnswerIsJudgedByLlamaGuardsOwnPrompt(t *testing.T) {
t.Errorf("a completion's text: %+v %v", j, err)
}
}
// Qwen3Guard's middle verdict counts as the policy says: safe unless it says
// unsafe.
func TestAControversialVerdictCountsAsThePolicySays(t *testing.T) {
ctx := context.Background()
lenient := judged(t, Policy{Judge: &JudgePolicy{Model: "guard", Input: "block"}}, &fakeJudge{format: "qwen3guard"})
if v, _ := lenient.Input(ctx, Subject{}, chat("user", "the plot of a heist film")); v.Action != Allow {
t.Errorf("controversial under the default: %+v", v)
}
strict := judged(t, Policy{Judge: &JudgePolicy{Model: "guard", Input: "block", Controversial: "unsafe"}}, &fakeJudge{format: "qwen3guard"})
if v, _ := strict.Input(ctx, Subject{}, chat("user", "the plot of a heist film")); v.Action != Block || v.Rule != "Non-violent Illegal Acts" {
t.Errorf("controversial: unsafe: %+v", v)
}
if err := (&JudgePolicy{Model: "guard", Input: "block", Controversial: "maybe"}).validate(); err == nil {
t.Error("controversial: maybe was taken")
}
}
// A judge on a GPU first and one on CPUs after: the first that answers
// decides, and the one that did not is said to have failed.
func TestJudgesAreAskedInTurn(t *testing.T) {
ctx := context.Background()
var asked []string
gpuDown := errors.New("no replica of gpu-judge is serving")
g := gate(t, lib(map[string]Policy{"p": {Fail: "closed",
Judge: &JudgePolicy{Models: []string{"gpu-judge", "cpu-judge"}, Input: "block"}}}, "p"))
g.SetJudge(func(_ context.Context, model, _ string, body []byte) ([]byte, error) {
asked = append(asked, model)
if model == "gpu-judge" {
return nil, gpuDown
}
unsafe := strings.Contains(string(body), "attack")
answer := map[bool]string{true: "Safety: Unsafe\nCategories: Violent", false: "Safety: Safe\nCategories: None"}[unsafe]
return json.Marshal(map[string]any{"choices": []any{map[string]any{"text": answer}}})
}, func(m string) (string, []string, bool) { return "qwen3guard", nil, true })
var alarmed []string
g.SetJudgeAlarm(func(model string, _ error) { alarmed = append(alarmed, model) })
if v, err := g.Input(ctx, Subject{}, chat("user", "plan an attack")); err != nil || v.Action != Block || v.Rule != "Violent" {
t.Errorf("unsafe: %+v %v", v, err)
}
if v, err := g.Input(ctx, Subject{}, chat("user", "continue")); err != nil || v.Action != Allow {
t.Errorf("safe: %+v %v", v, err)
}
if !slices.Equal(asked, []string{"gpu-judge", "cpu-judge", "gpu-judge", "cpu-judge"}) {
t.Errorf("asked %v", asked)
}
if !slices.Equal(alarmed, []string{"gpu-judge", "gpu-judge"}) {
t.Errorf("alarm told %v", alarmed)
}
if e := g.JudgeErrors()["gpu-judge"]; !strings.Contains(e.Err, "no replica") {
t.Errorf("the GPU judge's failure: %+v", e)
}
// Every judge down: the policy's fail applies, as with one.
g.SetJudge(func(context.Context, string, string, []byte) ([]byte, error) { return nil, gpuDown },
func(string) (string, []string, bool) { return "qwen3guard", nil, true })
if _, err := g.Input(ctx, Subject{}, chat("user", "hello")); err == nil {
t.Error("fail closed served what no judge read")
}
for _, bad := range []JudgePolicy{
{Model: "a", Models: []string{"b"}, Input: "block"},
{Models: []string{"a", "a"}, Input: "block"},
{Models: []string{""}, Input: "block"},
} {
if err := bad.validate(); err == nil {
t.Errorf("%+v was taken", bad)
}
}
}
func TestQwen3GuardIsAskedInItsOwnWords(t *testing.T) {
path, body := judgeRequest("qg", "qwen3guard", nil, "continue", "")
var req struct{ Prompt string }
_ = json.Unmarshal(body, &req)
if path != "/v1/completions" || !strings.HasPrefix(req.Prompt, "<|im_start|>user\n# Task:\n1) Evaluate whether THE LAST USER's query") ||
!strings.Contains(req.Prompt, "Jailbreak.\n<END UNSAFE CONTENT CATEGORIES>\n\n<BEGIN CONVERSATION>\n\nUSER: continue\n\n<END CONVERSATION>") ||
!strings.HasSuffix(req.Prompt, "'Categories: None'.<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n") {
t.Errorf("a request: %s %q", path, req.Prompt)
}
_, body = judgeRequest("qg", "qwen3guard", nil, "how do I bake bread", "Mix flour and water.")
_ = json.Unmarshal(body, &req)
if strings.Contains(req.Prompt, "Jailbreak") || !strings.Contains(req.Prompt, "<END REFUSAL POLICY>\n\n<BEGIN CONVERSATION>") ||
!strings.Contains(req.Prompt, "USER: how do I bake bread\n\nASSISTANT: Mix flour and water.\n\n<END CONVERSATION>") ||
!strings.Contains(req.Prompt, "'Categories: None'.\n - The third line must be one of: 'Refusal: Yes', 'Refusal: No'.<|im_end|>") {
t.Errorf("an answer: %q", req.Prompt)
}
// An answer's verdict has a line on refusing that says nothing of safety.
j, err := parseJudgment("qwen3guard", nil, []byte(`{"choices":[{"text":"Safety: Unsafe\nCategories: Violent, Jailbreak\nRefusal: No"}]}`), "")
if err != nil || !j.unsafe || !slices.Equal(j.categories, []string{"Violent", "Jailbreak"}) {
t.Errorf("%+v %v", j, err)
}
if j, err := parseJudgment("qwen3guard", nil, []byte(`{"choices":[{"text":"Safety: Safe\nCategories: None\nRefusal: Yes"}]}`), ""); err != nil || j.unsafe || len(j.categories) > 0 {
t.Errorf("%+v %v", j, err)
}
if _, err := parseJudgment("qwen3guard", nil, []byte(`{"choices":[{"text":"I think it is fine"}]}`), ""); err == nil {
t.Error("an answer with no verdict was read as one")
}
}
// A message with a file pasted into it is read by its start and its end, so
// it fits the judge's context instead of overflowing it.
func TestALongTextIsJudgedByItsEnds(t *testing.T) {
file := strings.Repeat("ğ", 10000) // two bytes a rune: a cut must not split one
msg := "review this file:\n" + file + "\nand plan an attack"
_, body := judgeRequest("qg", "qwen3guard", nil, msg, "")
var req struct{ Prompt string }
_ = json.Unmarshal(body, &req)
if !strings.Contains(req.Prompt, "USER: review this file:") || !strings.Contains(req.Prompt, "and plan an attack\n\n<END CONVERSATION>") ||
!strings.Contains(req.Prompt, "\n[…]\n") || len(req.Prompt) > judgeHead+judgeTail+3000 {
t.Errorf("%d bytes", len(req.Prompt))
}
if !utf8.ValidString(req.Prompt) {
t.Error("a rune was cut in two")
}
if got := ends("short", 10, 10); got != "short" {
t.Errorf("a short text was cut: %q", got)
}
}
+2 -2
View File
@@ -193,8 +193,8 @@ func (f *outFilter) askJudge(text string, final bool) finding {
res, err := f.g.judger.ask(f.ctx, jp, f.user, f.sofar.String())
f.took += time.Since(start)
if err != nil {
f.g.log.Warn("the judge gave no verdict on an answer", "judge", jp.Model, "err", err)
f.g.judgeFailed(jp.Model, err)
f.g.log.Warn("the judge gave no verdict on an answer", "judge", jp.Name(), "err", err)
f.g.noJudger(jp, err)
}
jf := judgeFinding(f.p, jp.Output, res, err)
if jf.failOpen {
+6
View File
@@ -25,10 +25,16 @@ import (
// Policies are attached to parts of the tenancy tree in the store; Default
// applies to every request.
type Config struct {
// Enabled turns every policy on or off at once: false checks nothing,
// whatever is defined or attached. Unset is on.
Enabled *bool `koanf:"enabled"`
Default []string `koanf:"default"`
Policies map[string]Policy `koanf:"policies"`
}
// On is whether the guardrails check anything.
func (c Config) On() bool { return c.Enabled == nil || *c.Enabled }
// Policy is one named set of rules.
type Policy struct {
// Mode: enforce acts, shadow only records what it would have done, off
+125
View File
@@ -0,0 +1,125 @@
// Package mail sends the gateway's few emails — a password reset link — by
// SMTP, when the cluster's settings say where through.
package mail
import (
"context"
"crypto/tls"
"errors"
"fmt"
"net"
"net/smtp"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/secrets"
)
// Config is where mail goes out.
type Config struct {
// SMTP is the relay, host:port: 587 with STARTTLS, 465 with TLS from the
// start, 25 as the relay allows. Empty sends nothing.
SMTP string `koanf:"smtp"`
// From is the sender, llmgw@example.com or "llmgw <llmgw@example.com>".
From string `koanf:"from"`
// 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.
Username string `koanf:"username"`
PasswordFile string `koanf:"password_file"`
// 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.
BaseURL string `koanf:"base_url"`
}
// Enabled is whether mail can be sent.
func (c Config) Enabled() bool { return c.SMTP != "" && c.From != "" && c.BaseURL != "" }
// Validate refuses a half-set configuration.
func (c Config) Validate() error {
if c.SMTP == "" && c.From == "" && c.BaseURL == "" {
return nil
}
if _, _, err := net.SplitHostPort(c.SMTP); err != nil {
return fmt.Errorf("mail.smtp %q: host:port, like smtp.example.com:587", c.SMTP)
}
if c.From == "" || !strings.Contains(c.From, "@") {
return errors.New("mail.from: the sender's address")
}
if !strings.HasPrefix(c.BaseURL, "https://") && !strings.HasPrefix(c.BaseURL, "http://") {
return fmt.Errorf("mail.base_url %q: the gateway's public address, https://…", c.BaseURL)
}
return nil
}
// Send sends one plain-text message.
func (c Config) Send(ctx context.Context, to, subject, body string) error {
if !c.Enabled() {
return errors.New("no mail relay is set (mail.smtp, mail.from, mail.base_url)")
}
host, port, _ := net.SplitHostPort(c.SMTP)
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
d := net.Dialer{}
var conn net.Conn
var err error
if port == "465" {
conn, err = (&tls.Dialer{NetDialer: &d, Config: &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}}).DialContext(ctx, "tcp", c.SMTP)
} else {
conn, err = d.DialContext(ctx, "tcp", c.SMTP)
}
if err != nil {
return fmt.Errorf("reaching %s: %w", c.SMTP, err)
}
if dl, ok := ctx.Deadline(); ok {
_ = conn.SetDeadline(dl)
}
cl, err := smtp.NewClient(conn, host)
if err != nil {
_ = conn.Close()
return err
}
defer cl.Close()
if ok, _ := cl.Extension("STARTTLS"); ok && port != "465" {
if err := cl.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
return fmt.Errorf("STARTTLS with %s: %w", c.SMTP, err)
}
}
if c.Username != "" {
pass, err := secrets.ReadString(ctx, c.PasswordFile)
if err != nil {
return fmt.Errorf("mail.password_file: %w", err)
}
if err := cl.Auth(smtp.PlainAuth("", c.Username, pass, host)); err != nil {
return fmt.Errorf("logging in to %s: %w", c.SMTP, err)
}
}
if err := cl.Mail(address(c.From)); err != nil {
return err
}
if err := cl.Rcpt(to); err != nil {
return err
}
w, err := cl.Data()
if err != nil {
return err
}
msg := "From: " + c.From + "\r\nTo: " + to + "\r\nSubject: " + subject +
"\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=utf-8\r\nDate: " +
time.Now().UTC().Format(time.RFC1123Z) + "\r\n\r\n" + strings.ReplaceAll(body, "\n", "\r\n")
if _, err := w.Write([]byte(msg)); err != nil {
return err
}
if err := w.Close(); err != nil {
return err
}
return cl.Quit()
}
// address is the bare address in "Name <a@b>".
func address(from string) string {
if i, j := strings.LastIndexByte(from, '<'), strings.LastIndexByte(from, '>'); i >= 0 && j > i {
return from[i+1 : j]
}
return strings.TrimSpace(from)
}
+93
View File
@@ -0,0 +1,93 @@
package mail
import (
"bufio"
"context"
"net"
"strings"
"testing"
)
// relay is the least of an SMTP server: it takes one message and keeps it.
func relay(t *testing.T) (addr string, got chan string) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
got = make(chan string, 1)
go func() {
c, err := ln.Accept()
if err != nil {
return
}
defer c.Close()
r := bufio.NewReader(c)
say := func(s string) { _, _ = c.Write([]byte(s + "\r\n")) }
say("220 relay ready")
var data strings.Builder
inData := false
for {
line, err := r.ReadString('\n')
if err != nil {
return
}
if inData {
if line == ".\r\n" {
inData = false
got <- data.String()
say("250 kept")
continue
}
data.WriteString(line)
continue
}
switch cmd := strings.ToUpper(strings.TrimSpace(line)); {
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
say("250 relay")
case strings.HasPrefix(cmd, "DATA"):
inData = true
say("354 go on")
case strings.HasPrefix(cmd, "QUIT"):
say("221 bye")
return
default:
say("250 ok")
}
}
}()
t.Cleanup(func() { _ = ln.Close() })
return ln.Addr().String(), got
}
func TestAMessageGoesThroughTheRelay(t *testing.T) {
addr, got := relay(t)
c := Config{SMTP: addr, From: "llmgw <llmgw@example.com>", BaseURL: "https://llm.example.com"}
if err := c.Validate(); err != nil {
t.Fatal(err)
}
if err := c.Send(context.Background(), "ayse@example.com", "Set a new password", "Hello,\nthe link."); err != nil {
t.Fatal(err)
}
msg := <-got
for _, want := range []string{"Subject: Set a new password", "To: ayse@example.com", "Hello,\r\nthe link."} {
if !strings.Contains(msg, want) {
t.Errorf("the message lacks %q:\n%s", want, msg)
}
}
}
func TestHalfAMailConfigurationIsRefused(t *testing.T) {
if err := (Config{}).Validate(); err != nil || (Config{}).Enabled() {
t.Errorf("none: %v", err)
}
if err := (Config{SMTP: "smtp.example.com:587"}).Validate(); err == nil {
t.Error("a relay without a sender was accepted")
}
if err := (Config{SMTP: "smtp.example.com:587", From: "a@b.c", BaseURL: "llm.example.com"}).Validate(); err == nil {
t.Error("a base URL without a scheme was accepted")
}
if got := address("llmgw <llmgw@example.com>"); got != "llmgw@example.com" {
t.Errorf("%q", got)
}
}
+3
View File
@@ -505,6 +505,9 @@ func (r *Runner) check(ctx context.Context, res *Result) error {
if fails > 0 && !goOn {
return fmt.Errorf("%w: doctor found %d; fix them and run this again, or add --ignore-checks", ErrChecks, fails)
}
if rd := doctor.ReadinessOf(rep.Findings); rd.MultiNode != "not used" {
r.emit(StepCheck, Info, "multi-node: "+rd.MultiNode+"; replicas of its own run either way", "")
}
r.emit(StepCheck, Done, fmt.Sprintf("%d problem(s), %d warning(s)", fails, countLevel(rep.Findings, doctor.Warn)), "")
return nil
}
+1 -1
View File
@@ -191,7 +191,7 @@ func guardFields(u *store.Usage, in, out guard.Verdict) {
if out.Stricter(in) {
v = out
}
u.GuardAction, u.GuardPolicy, u.GuardCategory = string(v.Action), v.Policy, v.Category
u.GuardAction, u.GuardPolicy, u.GuardCategory, u.GuardRule = string(v.Action), v.Policy, v.Category, v.Rule
if v.Shadow && v.Action != guard.Allow {
u.GuardAction = "shadow-" + u.GuardAction
}
+2 -1
View File
@@ -34,7 +34,7 @@ func (g *wordGuard) Input(_ context.Context, s guard.Subject, body []byte) (guar
case g.err != nil:
return guard.Verdict{}, g.err
case bytes.Contains(body, []byte("forbidden")):
return guard.Verdict{Action: guard.Block, Policy: "words", Category: "test"}, nil
return guard.Verdict{Action: guard.Block, Policy: "words", Category: "test", Rule: "S6,Specialized Advice"}, nil
case bytes.Contains(body, []byte("alice@example.com")):
return guard.Verdict{Action: guard.Redact, Policy: "pii", Category: "email",
Body: bytes.ReplaceAll(body, []byte("alice@example.com"), []byte("[EMAIL_1]"))}, nil
@@ -157,6 +157,7 @@ func TestABlockedRequestIsRecordedWithItsVerdictAndCostsNothing(t *testing.T) {
}
if ok {
if u.GuardAction != "blocked" || u.GuardPolicy != "words" || u.GuardCategory != "test" ||
u.GuardRule != "S6,Specialized Advice" ||
u.CostMicros != 0 || u.PromptTokens != 0 || u.Status != http.StatusBadRequest {
t.Errorf("recorded %+v", u)
}
+24 -4
View File
@@ -217,6 +217,9 @@ func (r *Router) Handler() http.Handler {
pages.SetPersonalDefaults(r.personalDefaults)
if r.store != nil {
pages.SetAudit(r.store)
// A forgotten password: a link mailed when the cluster's
// settings say how, otherwise asked of an operator.
pages.SetPasswordReset(r.store, r.mail, r.mail.BaseURL)
if r.oidc.Enabled() {
if err := pages.RegisterOIDC(mux, r.oidc, r.store); err != nil {
r.log.Error("single sign-on is off", "err", err)
@@ -307,7 +310,7 @@ func (r *Router) Handler() http.Handler {
}
if r.auth == nil {
return r.refuseLoops(mux)
return r.refuseWhileStopped(r.refuseLoops(mux))
}
exempt := []string{"/health", "/ready", "/leader", "/", "/about", "/_static/", "/_lang/", "/claim/",
"/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/",
@@ -321,7 +324,22 @@ func (r *Router) Handler() http.Handler {
// Wrapping the whole mux rather than checking inside each handler: adding a
// route must not be able to add an unauthenticated one by omission. Health
// stays open because a load balancer has no key.
return r.refuseLoops(auth.Middleware(r.auth, r.log, exempt...)(mux))
return r.refuseWhileStopped(r.refuseLoops(auth.Middleware(r.auth, r.log, exempt...)(mux)))
}
// refuseWhileStopped answers every request for a model with 503 while the
// operators have stopped the service (llmgw stop): the models are being taken
// down, and a client is better told so plainly than left to time out.
func (r *Router) refuseWhileStopped(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
if r.stopped.Load() && req.Method == http.MethodPost && strings.HasPrefix(req.URL.Path, "/v1/") {
w.Header().Set("Retry-After", "300")
writeErrorCode(w, http.StatusServiceUnavailable, "server_error", "system_stopped",
"this gateway's service has been stopped by its operators; it answers again once they start it")
return
}
next.ServeHTTP(w, req)
})
}
// parsedRequest is what the router needs from a request. Everything else in the
@@ -1217,9 +1235,11 @@ func (r *Router) accessLog(req *parsedRequest, res dispatchResult, status int, t
}
// What a guard did, by name: never what it matched.
if v := req.guardIn; res.guard.Stricter(v) {
attrs = append(attrs, "guard", string(res.guard.Action), "guard_stage", "output", "guard_policy", res.guard.Policy)
attrs = append(attrs, "guard", string(res.guard.Action), "guard_stage", "output", "guard_policy", res.guard.Policy,
"guard_category", res.guard.Category, "guard_rule", res.guard.Rule)
} else if v.Action != "" {
attrs = append(attrs, "guard", string(v.Action), "guard_stage", "input", "guard_policy", v.Policy)
attrs = append(attrs, "guard", string(v.Action), "guard_stage", "input", "guard_policy", v.Policy,
"guard_category", v.Category, "guard_rule", v.Rule)
}
if res.outcome.Stopped {
attrs = append(attrs, "stopped_by_guard", true)
+10 -1
View File
@@ -9,6 +9,7 @@ import (
"context"
"errors"
"fmt"
"gitea.mynodes.xyz/ilker/llmgw/internal/mail"
"log/slog"
"math"
"net"
@@ -112,7 +113,11 @@ type Router struct {
// oidc is sign-in through an identity provider, and scim provisioning
// by it.
oidc config.OIDC
scim config.SCIM
// mail sends what the portal mails: a password reset link.
mail mail.Config
// stopped is the operators having stopped the service (llmgw stop).
stopped atomic.Bool
scim config.SCIM
// threads holds conversations and memory notes. It is store when auth is on,
// its own file when only the threads block is configured, and nil when
@@ -189,6 +194,9 @@ func (r *Router) SetGuard(g guard.Guard) { r.guard = g }
// SetGuardLibrary is the policies the guard was built from, for the pages.
func (r *Router) SetGuardLibrary(c func() guard.Config) { r.guardLibrary = c }
// SetStopped refuses, or again serves, every request for a model.
func (r *Router) SetStopped(stopped bool) { r.stopped.Store(stopped) }
// SetRegistry gives the mirror page somewhere to look models up.
func (r *Router) SetRegistry(reg web.Registry) { r.registry = reg }
@@ -332,6 +340,7 @@ func New(cfg config.Server, log *slog.Logger) (*Router, error) {
st.SetPersonalDefaults(personalLimits(cfg.Auth.Personal))
r.personalOff = cfg.Auth.Personal.Disabled
r.oidc = cfg.Auth.OIDC
r.mail = cfg.Mail
r.scim = cfg.Auth.SCIM
r.personalDefaults = personalLimits(cfg.Auth.Personal)
r.llamaVersion = cfg.WantedEngines()["llamacpp"]
+21
View File
@@ -748,3 +748,24 @@ func TestModelsEndpointListsOnlyWhatTheKeyMayUse(t *testing.T) {
t.Errorf("listed %v; the key may use real and its alias only", ids)
}
}
func TestAStoppedServiceAnswers503AndStillListsModels(t *testing.T) {
e := newFakeEngine(t, "m")
rt := newTestRouter(t, baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: []string{"m"}}))
h := rt.Handler()
rt.SetStopped(true)
rec := post(t, h, map[string]any{"model": "m", "messages": []map[string]string{{"role": "user", "content": "hi"}}}, nil)
if rec.Code != http.StatusServiceUnavailable || !strings.Contains(rec.Body.String(), "system_stopped") ||
rec.Header().Get("Retry-After") == "" {
t.Fatalf("stopped: %d %s", rec.Code, rec.Body.String())
}
list := httptest.NewRecorder()
h.ServeHTTP(list, httptest.NewRequest(http.MethodGet, "/v1/models", nil))
if list.Code == http.StatusServiceUnavailable {
t.Error("listing models was refused too")
}
rt.SetStopped(false)
if rec := post(t, h, map[string]any{"model": "m", "messages": []map[string]string{{"role": "user", "content": "hi"}}}, nil); rec.Code == http.StatusServiceUnavailable {
t.Errorf("started again, still refused: %s", rec.Body.String())
}
}
+2
View File
@@ -60,6 +60,8 @@ var copyOrder = []string{
"guard_attachments",
// Or the cluster would start the new store on server.yaml's settings again.
"cluster_settings",
// A link sent and not yet used still works after the move.
"password_resets",
// Who changed what is not lost by moving the store.
"audit_events",
"key_ttl",
+3 -2
View File
@@ -50,6 +50,7 @@ type GuardEvent struct {
Action string
Policy string
Category string
Rule string
GuardMs int64
}
@@ -57,14 +58,14 @@ type GuardEvent struct {
// flagged, redacted, blocked, or would have in shadow — since a moment.
func (s *DB) GuardEvents(ctx context.Context, since time.Time, limit int) ([]GuardEvent, error) {
return queryAll(ctx, s.db, `SELECT ts, request_id, key_id, COALESCE(user_id, ''), project_id, model, status,
guard_action, guard_policy, guard_category, guard_ms
guard_action, guard_policy, guard_category, guard_rule, guard_ms
FROM usage WHERE ts >= ? AND guard_action <> '' ORDER BY ts DESC LIMIT ?`,
[]any{millis(since), limit},
func(rows *sql.Rows) (GuardEvent, error) {
var e GuardEvent
var ts int64
err := rows.Scan(&ts, &e.RequestID, &e.KeyID, &e.UserID, &e.ProjectID, &e.Model, &e.Status,
&e.Action, &e.Policy, &e.Category, &e.GuardMs)
&e.Action, &e.Policy, &e.Category, &e.Rule, &e.GuardMs)
e.At = fromMillis(ts)
return e, err
})
@@ -0,0 +1,2 @@
-- Ported from the SQLite migration of the same number.
ALTER TABLE usage ADD COLUMN guard_rule TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,8 @@
-- Ported from the SQLite migration of the same number.
CREATE TABLE password_resets (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at BIGINT NOT NULL,
expires_at BIGINT NOT NULL,
used_at BIGINT NOT NULL DEFAULT 0
);
@@ -0,0 +1,5 @@
-- What in a guard policy decided a request, beside its category: the
-- pattern's name, or the judge's verdict — "S6,Specialized Advice" — so a
-- request a judge refused says which hazard it saw, and a judge that
-- refuses ordinary questions can be told from one that is right to.
ALTER TABLE usage ADD COLUMN guard_rule TEXT NOT NULL DEFAULT '';
@@ -0,0 +1,10 @@
-- One-time links to set a forgotten password: kept by the token's hash, as
-- sessions are, so a copy of the store opens nobody's account. A link is
-- good once, until it expires.
CREATE TABLE password_resets (
token_hash TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used_at INTEGER NOT NULL DEFAULT 0
);
+63
View File
@@ -0,0 +1,63 @@
package store
import (
"context"
"database/sql"
"encoding/hex"
"errors"
"time"
)
// CreatePasswordReset mints a one-time link token to set userID's password,
// good for ttl. Only its hash is kept.
func (s *DB) CreatePasswordReset(ctx context.Context, userID string, ttl time.Duration) (string, error) {
token, err := NewSessionToken()
if err != nil {
return "", err
}
now := time.Now()
// Older links for the same person stop working: the newest is the one
// they were sent.
if _, err := s.db.ExecContext(ctx,
`UPDATE password_resets SET used_at = ? WHERE user_id = ? AND used_at = 0`, millis(now), userID); err != nil {
return "", err
}
_, err = s.db.ExecContext(ctx,
`INSERT INTO password_resets (token_hash, user_id, created_at, expires_at) VALUES (?, ?, ?, ?)`,
resetHash(token), userID, millis(now), millis(now.Add(ttl)))
return token, err
}
// PasswordResetUser is whose a reset token is, while it is good: ErrNotFound
// for one used, expired or never made.
func (s *DB) PasswordResetUser(ctx context.Context, token string) (string, error) {
var userID string
err := s.db.QueryRowContext(ctx,
`SELECT user_id FROM password_resets WHERE token_hash = ? AND used_at = 0 AND expires_at > ?`,
resetHash(token), millis(time.Now())).Scan(&userID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
return userID, err
}
// UsePasswordReset spends a reset token, returning whose it was. Of two uses
// at once, one wins and the other gets ErrNotFound.
func (s *DB) UsePasswordReset(ctx context.Context, token string) (string, error) {
userID, err := s.PasswordResetUser(ctx, token)
if err != nil {
return "", err
}
res, err := s.db.ExecContext(ctx,
`UPDATE password_resets SET used_at = ? WHERE token_hash = ? AND used_at = 0`,
millis(time.Now()), resetHash(token))
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n != 1 {
return "", ErrNotFound
}
return userID, nil
}
func resetHash(token string) string { return hex.EncodeToString(HashKey(token)) }
+36
View File
@@ -0,0 +1,36 @@
package store
import (
"errors"
"testing"
"time"
)
func TestAResetLinkWorksOnceAndUntilItExpires(t *testing.T) {
s := capacityStore(t)
ctx := t.Context()
tok, err := s.CreatePasswordReset(ctx, "usr-1", time.Hour)
if err != nil {
t.Fatal(err)
}
if u, err := s.PasswordResetUser(ctx, tok); err != nil || u != "usr-1" {
t.Fatalf("%q %v", u, err)
}
if u, err := s.UsePasswordReset(ctx, tok); err != nil || u != "usr-1" {
t.Fatalf("first use: %q %v", u, err)
}
if _, err := s.UsePasswordReset(ctx, tok); !errors.Is(err, ErrNotFound) {
t.Errorf("used twice: %v", err)
}
expired, _ := s.CreatePasswordReset(ctx, "usr-2", -time.Minute)
if _, err := s.PasswordResetUser(ctx, expired); !errors.Is(err, ErrNotFound) {
t.Errorf("an expired link: %v", err)
}
first, _ := s.CreatePasswordReset(ctx, "usr-3", time.Hour)
if _, err := s.CreatePasswordReset(ctx, "usr-3", time.Hour); err != nil {
t.Fatal(err)
}
if _, err := s.PasswordResetUser(ctx, first); !errors.Is(err, ErrNotFound) {
t.Errorf("an older link still works after a newer one: %v", err)
}
}
+5 -5
View File
@@ -435,8 +435,8 @@ func (s *DB) WriteUsage(ctx context.Context, records []Usage) error {
`INSERT INTO usage (ts, key_id, project_id, model, served_model,
prompt_tokens, completion_tokens, cost_micros, status, latency_ms, user_id,
request_id, replica, queue_ms, ttft_ms,
guard_action, guard_policy, guard_category, guard_ms, end_user, tags, cache_hit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
guard_action, guard_policy, guard_category, guard_rule, guard_ms, end_user, tags, cache_hit)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
if err != nil {
return err
}
@@ -450,7 +450,7 @@ func (s *DB) WriteUsage(ctx context.Context, records []Usage) error {
r.Model, r.ServedModel, r.PromptTokens, r.CompletionTokens,
r.CostMicros, r.Status, r.LatencyMs, r.UserID,
r.RequestID, r.Replica, r.QueueMs, r.TTFTMs,
r.GuardAction, r.GuardPolicy, r.GuardCategory, r.GuardMs, r.EndUser, tagsJSON(r.Tags), hit); err != nil {
r.GuardAction, r.GuardPolicy, r.GuardCategory, r.GuardRule, r.GuardMs, r.EndUser, tagsJSON(r.Tags), hit); err != nil {
return err
}
}
@@ -864,11 +864,11 @@ func (s *DB) UsageByRequest(ctx context.Context, requestID string) (Usage, bool,
err := s.db.QueryRowContext(ctx,
`SELECT ts, key_id, project_id, COALESCE(user_id, ''), model, served_model, prompt_tokens,
completion_tokens, cost_micros, status, latency_ms, request_id, replica, queue_ms, ttft_ms,
guard_action, guard_policy, guard_category, guard_ms, end_user, tags, cache_hit
guard_action, guard_policy, guard_category, guard_rule, guard_ms, end_user, tags, cache_hit
FROM usage WHERE request_id = ? ORDER BY ts DESC LIMIT 1`, requestID).
Scan(&ts, &u.KeyID, &u.ProjectID, &u.UserID, &u.Model, &u.ServedModel, &u.PromptTokens,
&u.CompletionTokens, &u.CostMicros, &u.Status, &u.LatencyMs, &u.RequestID, &u.Replica,
&u.QueueMs, &u.TTFTMs, &u.GuardAction, &u.GuardPolicy, &u.GuardCategory, &u.GuardMs,
&u.QueueMs, &u.TTFTMs, &u.GuardAction, &u.GuardPolicy, &u.GuardCategory, &u.GuardRule, &u.GuardMs,
&u.EndUser, &tags, &hit)
if errors.Is(err, sql.ErrNoRows) {
return Usage{}, false, nil
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff. Show more