Compare commits
156
Commits
settings-e
...
v0.15.0
No files matched your search
@@ -34,7 +34,11 @@ jobs:
|
|||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
- name: Test with the race detector
|
- name: Test with the race detector
|
||||||
run: go test -race ./...
|
# Under the job's limit, so a package that hangs panics with the
|
||||||
|
# test it was in and where every goroutine waited, rather than the
|
||||||
|
# job ending at its deadline with nothing said: from 2026-10-15 every
|
||||||
|
# run did, printing nothing after internal/release.
|
||||||
|
run: go test -race -timeout 12m ./...
|
||||||
|
|
||||||
- name: Build
|
- name: Build
|
||||||
run: go build ./...
|
run: go build ./...
|
||||||
|
|||||||
@@ -9,3 +9,5 @@ coverage.out
|
|||||||
/terraform-provider-llmgw/terraform-provider-llmgw
|
/terraform-provider-llmgw/terraform-provider-llmgw
|
||||||
/terraform-provider-llmgw/examples/.terraform*
|
/terraform-provider-llmgw/examples/.terraform*
|
||||||
/terraform-provider-llmgw/examples/terraform.tfstate*
|
/terraform-provider-llmgw/examples/terraform.tfstate*
|
||||||
|
# What a development server run from the checkout writes beside it.
|
||||||
|
/secrets/
|
||||||
+783
-14
@@ -15,6 +15,52 @@ Conventions this codebase keeps, which every item here must keep too:
|
|||||||
- `go test ./...`, `go vet ./...` and `govulncheck ./...` pass. No release is
|
- `go test ./...`, `go vet ./...` and `govulncheck ./...` pass. No release is
|
||||||
cut and nothing is deployed to the PoC cluster without being told to.
|
cut and nothing is deployed to the PoC cluster without being told to.
|
||||||
|
|
||||||
|
## Open, in order (2026-10-20)
|
||||||
|
|
||||||
|
What is left, most needed first. The detail is in the item each names.
|
||||||
|
|
||||||
|
**Now: what goes wrong where it is installed.**
|
||||||
|
1. ~~**J8**~~: done 2026-10-20. `ctl guard judge check`, and init times the judge. Left of **J3**: its load test
|
||||||
|
against judges on the control plane, which was stopped half way. Judges on a 4-core node of their own refused
|
||||||
|
0/60 at 2 at once, 13/60 at 4 and 42/60 at 10.
|
||||||
|
2. ~~**I2 P2**~~: done 2026-10-20: the guardrails both ways, a rate per caller and server, a breaker. A portal page
|
||||||
|
for the servers is left, with P3 (stdio, per-person OAuth).
|
||||||
|
3. **K**: fixes from the fabric test the operator runs with docs/large-models.md.
|
||||||
|
|
||||||
|
**Soon: the customer's experience, and HA.**
|
||||||
|
4. ~~**C24**~~: done 2026-10-21. The store's, catalog's and mirror's errors, percent signs, footer times and stale
|
||||||
|
forms are in the page's language. Left: the /docs documents, and a language kept per person (cookie only today).
|
||||||
|
5. **J4**: the model mirror on more than one machine, the last single point of an HA install. (M)
|
||||||
|
6. **F5**: large imports limited by the embedding engine's slots. (M)
|
||||||
|
|
||||||
|
**From Kong** (section L): L1 done; L9, then L2 and L3, then L4/L5. L6 and L7 are llmgw-80's.
|
||||||
|
|
||||||
|
**Governance.**
|
||||||
|
7. **C1**: a tenant-admin role, and forms hidden from viewers.
|
||||||
|
8. **Audit before and after**: what a change changed, and changes made with the CLI recorded too.
|
||||||
|
|
||||||
|
**When there is room.**
|
||||||
|
9. **I1**: answers to similar questions from the cache. (M)
|
||||||
|
10. **H1**: tried on the PoC.
|
||||||
|
11. **C4**: Parquet, and a scheduled push for usage export.
|
||||||
|
|
||||||
|
**When needed.**
|
||||||
|
12. **H5**: licences in video and audio. 13. **H3**: replicas shared, the catalog side. 14. **J7**: nginx in front
|
||||||
|
of several control planes. 15. **J6**: rate limits per process (a note).
|
||||||
|
|
||||||
|
**Done since 2026-10-16:**
|
||||||
|
- J1, J5, I3, I4, J2.
|
||||||
|
- Active Directory and Entra ID, tried against a Samba DC, with the console's Sign-in screen.
|
||||||
|
- J3's code.
|
||||||
|
- K's code and docs/large-models.md.
|
||||||
|
- I2 P1 and the cluster's own read-only MCP server (llmgw:admin).
|
||||||
|
- The node console's Overview; a node's page showing its version and the models on each GPU.
|
||||||
|
|
||||||
|
**Not code, the operator's to decide or do:**
|
||||||
|
- Where Alertmanager sends (its receiver is "none"): with a judge down, nobody hears.
|
||||||
|
- The fabric test for K.
|
||||||
|
- The PoC's control planes run a dev build of main (v0.14.9-11-g4eb49d0); its nodes run v0.14.8.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## A. Security — do first
|
## A. Security — do first
|
||||||
@@ -386,10 +432,9 @@ portal is not done — the page shows; the console and ctl change.
|
|||||||
tenant-admin / billing; `ctl audit`. Depends on A5 for a real actor.
|
tenant-admin / billing; `ctl audit`. Depends on A5 for a real actor.
|
||||||
2. **Done (branch `features-c`):** OIDC sign-in (`auth.oidc`, PKCE,
|
2. **Done (branch `features-c`):** OIDC sign-in (`auth.oidc`, PKCE,
|
||||||
verified email, roles and memberships from groups, `no_passwords`) and
|
verified email, roles and memberships from groups, `no_passwords`) and
|
||||||
SCIM 2.0 (`/scim/v2` Users and Groups). Left: the sign-in cookie is
|
SCIM 2.0 (`/scim/v2` Users and Groups). The sign-in cookie's key comes
|
||||||
signed with a per-process key, so with two control planes behind a
|
from the store (2026-10-10), so a callback may reach either control
|
||||||
balancer the callback must reach the one that started it (sticky
|
plane behind a balancer. **OIDC/SSO login (M), then SCIM (M).** `internal/account/service.go`,
|
||||||
sessions) until the key comes from the store. **OIDC/SSO login (M), then SCIM (M).** `internal/account/service.go`,
|
|
||||||
`internal/web/account.go`; map IdP groups to org/team/admin.
|
`internal/web/account.go`; map IdP groups to org/team/admin.
|
||||||
3. **Done (branch `features-c`):** key lifetime limits (gateway, org, team,
|
3. **Done (branch `features-c`):** key lifetime limits (gateway, org, team,
|
||||||
project), `keys rotate --overlap`, kinds and expiry in `keys list`.
|
project), `keys rotate --overlap`, kinds and expiry in `keys list`.
|
||||||
@@ -405,8 +450,9 @@ portal is not done — the page shows; the console and ctl change.
|
|||||||
lost, budget spent, update available; signed JSON or Slack; retries,
|
lost, budget spent, update available; signed JSON or Slack; retries,
|
||||||
hourly de-duplication), `ctl notify test`, Alertmanager receiver
|
hourly de-duplication), `ctl notify test`, Alertmanager receiver
|
||||||
templates. Left: `server init` and the console do not yet ask for a
|
templates. Left: `server init` and the console do not yet ask for a
|
||||||
receiver — the templates are filled in by hand; each control plane sends
|
receiver — the templates are filled in by hand. Events are claimed in
|
||||||
budget events on its own, so two behind a balancer may both send one.
|
the store before they are sent (2026-10-10), so two control planes send
|
||||||
|
each once.
|
||||||
**Alerting out of the box (S).** `deploy/monitoring/alertmanager.yml`
|
**Alerting out of the box (S).** `deploy/monitoring/alertmanager.yml`
|
||||||
ships `receiver: none`; add Slack/email/webhook/PagerDuty receiver
|
ships `receiver: none`; add Slack/email/webhook/PagerDuty receiver
|
||||||
templates driven by `server init` or the console; llmgw-native webhooks
|
templates driven by `server init` or the console; llmgw-native webhooks
|
||||||
@@ -569,9 +615,12 @@ portal is not done — the page shows; the console and ctl change.
|
|||||||
client tests against a fake admin API and the OpenAPI document;
|
client tests against a fake admin API and the OpenAPI document;
|
||||||
acceptance tests with Terraform and OpenTofu against the fake; one
|
acceptance tests with Terraform and OpenTofu against the fake; one
|
||||||
apply, empty plan, in-place change and destroy against a real `llmgw
|
apply, empty plan, in-place change and destroy against a real `llmgw
|
||||||
server` run by hand. Left: organizations, teams, projects, people and
|
server` run by hand. Organizations, teams, projects, keys and
|
||||||
keys are not in the admin API (they are `llmgw org`/`users`/`keys`
|
memberships are in the admin API since 2026-10-08 (`/api/v1/orgs`,
|
||||||
against the store), so not in the provider; a model's definition beyond
|
`teams`, `projects`, `keys`, `users/{id}/memberships`) and in the
|
||||||
|
provider (`llmgw_org`, `llmgw_team`, `llmgw_project`, `llmgw_key`,
|
||||||
|
`llmgw_membership`); accounts are still made by sign-up, invitation or
|
||||||
|
OIDC/SCIM. Left: a model's definition beyond
|
||||||
scaling, class, owner and adapters is changed by replacing it (the API
|
scaling, class, owner and adapters is changed by replacing it (the API
|
||||||
has no edit, and `resources` is not used yet); `canary`, `slo`,
|
has no edit, and `resources` is not used yet); `canary`, `slo`,
|
||||||
`scaling.schedule` and `prewarm`, `guard` role and `origin` are not in
|
`scaling.schedule` and `prewarm`, `guard` role and `origin` are not in
|
||||||
@@ -793,10 +842,19 @@ leader 30s later with /health answering throughout; rollback did the same.
|
|||||||
facility state from the store (it kept what it read at start).
|
facility state from the store (it kept what it read at start).
|
||||||
- Retires `appendGuardIfMissing` (server init): the import does it.
|
- Retires `appendGuardIfMissing` (server init): the import does it.
|
||||||
|
|
||||||
### E2. Generated files from settings (L) — firewall on/off done 2026-10-06
|
### E2. Generated files from settings (L) — firewall on/off done 2026-10-06, every machine 2026-10-09
|
||||||
`firewall.enabled` in the cluster's settings reaches nodes over the agent
|
`firewall.enabled` in the cluster's settings reaches nodes over the agent
|
||||||
stream and control planes through llmgw-firewall-sync.path. Left: the mirror
|
stream and control planes through llmgw-firewall-sync.path. Since
|
||||||
machine and store members, the allowed set from membership, the other files.
|
2026-10-09 the mirror machine too (the leader PUTs /v1/firewall, the mirror
|
||||||
|
writes `.firewall.json`, the same path unit applies it) and store members
|
||||||
|
that run no llmgw (the leader keeps it in `cluster_state`, a root timer
|
||||||
|
reads the member's own copy with psql); `firewall_networks` reach all of
|
||||||
|
them, and the mirror lets in the network of every node and control plane
|
||||||
|
the leader knows. Tried on the PoC: off and on, and a network added, on
|
||||||
|
control planes, mirror, store member and nodes. Left: the allowed set
|
||||||
|
replaced by membership rather than added to (a network taken out of the
|
||||||
|
settings stays let in), the other files (the mirror's unit flags,
|
||||||
|
keepalived, etcd and Patroni configuration).
|
||||||
|
|
||||||
Every machine runs the agent (root) with roles — node, control plane, mirror,
|
Every machine runs the agent (root) with roles — node, control plane, mirror,
|
||||||
store member — and the agent renders and applies what was written once by
|
store member — and the agent renders and applies what was written once by
|
||||||
@@ -863,6 +921,12 @@ Built (`internal/store/knowledge.go`, migration 0035 PostgreSQL only):
|
|||||||
partial HNSW index per collection on its organization's partition
|
partial HNSW index per collection on its organization's partition
|
||||||
(`embedding::vector(dims)`, `WHERE collection = '<id>'`). EXPLAIN on
|
(`embedding::vector(dims)`, `WHERE collection = '<id>'`). EXPLAIN on
|
||||||
20k rows: partition pruned, that index used, policy applied.
|
20k rows: partition pruned, that index used, policy applied.
|
||||||
|
Later (scale, 2026-10-08): a partial GIN per collection too
|
||||||
|
(`kb_tsv_<id>`, backfilled at open); `vec_type` per collection, `halfvec`
|
||||||
|
on pgvector ≥ 0.7 (up to 4000 dims); `SET LOCAL hnsw.ef_search` to
|
||||||
|
candidates(k) clamped 40..1000 (EXPLAIN: 40 rows without it, all with
|
||||||
|
it); the text arm takes candidates by `ts_rank` in a subquery and scores
|
||||||
|
only those.
|
||||||
- RLS FORCEd on `kb_chunks` against `llmgw.kb_org`/`llmgw.kb_collections`
|
- RLS FORCEd on `kb_chunks` against `llmgw.kb_org`/`llmgw.kb_collections`
|
||||||
(set with set_config per transaction), one role — the table owner — not
|
(set with set_config per transaction), one role — the table owner — not
|
||||||
a separate one. Chunks are deleted by the foreign-key cascade from their
|
a separate one. Chunks are deleted by the foreign-key cascade from their
|
||||||
@@ -1005,7 +1069,7 @@ a request without the header, `none` turns it off, an explicit header with
|
|||||||
k=2 gives two sources and no more, llmgw-kb lists owners by path. Left: returned passages
|
k=2 gives two sources and no more, llmgw-kb lists owners by path. Left: returned passages
|
||||||
are not audited per request (the header and the kept request record them).
|
are not audited per request (the header and the kept request record them).
|
||||||
|
|
||||||
### F4. Adapter training (L, after F1–F3)
|
### F4. Adapter training (L, after F1–F3) — done 2026-10-08 (branch `adapters-f4`)
|
||||||
A run-to-completion job placed by the control plane like a replica and
|
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
|
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
|
installs (TRL + PEFT, QLoRA). Data: Q/A pairs generated from a collection by
|
||||||
@@ -1013,12 +1077,717 @@ a cluster model through `/v1/batches`, reviewed by people. Output to the
|
|||||||
mirror, into the model's `adapters`, through canary and a quality suite.
|
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.
|
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.
|
Never train on a collection some users of the adapter may not read.
|
||||||
|
Tried 2026-10-08 on the PoC, by hand: Qwen2.5-0.5B-Instruct (fp16) with a
|
||||||
|
LoRA (r=16, every linear layer, 8.8M trainable), trained beside the 35B in
|
||||||
|
cuda01's last 2.3 GB (peak 2158 MiB on the card, 64 s for 138 steps, PEFT in a
|
||||||
|
venv over the vLLM engine's torch). Data: 90 Turkish Q/A pairs a cluster
|
||||||
|
model wrote from the nine Demo/politikalar documents, through the gateway.
|
||||||
|
On nine questions it had not seen, phrased otherwise: base 0/9, 6 epochs
|
||||||
|
4/9, 4 epochs at a lower rate 2/9. Wrong answers mixed facts across
|
||||||
|
documents (the meal card's amount, the office day). The same nine through
|
||||||
|
retrieval: 9/9 with sources. So F4 is for format, vocabulary and tone, and
|
||||||
|
facts come from F1–F3; a bigger base on a free card would do better but not
|
||||||
|
change that. The training itself fits a small slot on a shared card, which
|
||||||
|
the job design can rely on for small bases.
|
||||||
|
|
||||||
|
Built: `ctl kb dataset` (POST /api/v1/kb/collections/{id}/dataset, NDJSON
|
||||||
|
with each pair's collection); train_jobs/train_artifacts (migrations 0037,
|
||||||
|
0038 owner); internal/control/train (queue, one job per node, artifacts in
|
||||||
|
1 MiB pieces, a node that goes fails its job); internal/agent/train (the
|
||||||
|
newest vLLM's python3 + `uv pip install --no-deps --target` peft 0.21.2 and
|
||||||
|
accelerate 1.15.0; llama.cpp's conversion code for the installed release,
|
||||||
|
untarred from its source; the card with the most free memory, if enough;
|
||||||
|
runs as llmgw-engine); `ctl train start|list|show --follow|cancel|rm|
|
||||||
|
download|publish`; the mirror's PUT /v1/models/{model}/{version}/{file};
|
||||||
|
publish uploads the GGUF (llama.cpp) or PEFT files (vLLM) and sets the
|
||||||
|
adapter after checkAdapters. Owners: catalog.Adapter.Owner, enforced in the
|
||||||
|
router (404, hidden from /v1/models); a job's owner defaults to its single
|
||||||
|
collection's owner, must be able to read every collection the pairs came
|
||||||
|
from, and pairs from a knowledge base are never public. Tried on the PoC:
|
||||||
|
dataset of 90 pairs in 89 s; a job trained beside the 35B (peak 2214 MiB,
|
||||||
|
138 steps in about a minute) and sent back PEFT and GGUF.
|
||||||
|
Left: a job dies with its agent's stream (no resume); no console screen for
|
||||||
|
training yet; quality gating of a published adapter is by hand (ctl eval).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### F5. Large imports (M) — measured 2026-10-09, open
|
||||||
|
The metallurgy corpus (2,314 files, 108,779 chunks, bge-m3 on the one
|
||||||
|
GPU) went in at about 25 chunks/s, the same over a phone hotspot and the
|
||||||
|
LAN. The engine's slots were the limit: llama.cpp `--parallel 4` embeds
|
||||||
|
four texts per pass, while llmgw-kb sends 32 a request from 6 workers. The
|
||||||
|
usage rows show 4.6 s a request on average, 1.8 s of it in the gateway's
|
||||||
|
queue and 0.04 s in the guard. The GPU sat at 18–50% and the store machine
|
||||||
|
at a third of its CPUs.
|
||||||
|
- An embedding model's profile with many slots by default (`--parallel`
|
||||||
|
32–64, `--ctx-size` to match, `--batch-size`/`--ubatch-size` at least the
|
||||||
|
longest chunk), or `ctl model serve` choosing that for a model with
|
||||||
|
`capabilities.embeddings`. With the default 512, one 516-token chunk failed.
|
||||||
|
- Embedding on the gateway's side of a document PUT, so the client sends
|
||||||
|
text only: today an embeddings answer is ~21 KB of JSON a chunk, and
|
||||||
|
the text and vector go up again (~11 KB).
|
||||||
|
- The store next: HNSW inserts took half of one of pg01's two cores; at a
|
||||||
|
million chunks the vectors alone outgrow its 4 GB. halfvec (pgvector ≥
|
||||||
|
0.7) and a larger store machine.
|
||||||
|
- Done: a batch of embeddings checked text by text against the context
|
||||||
|
(branch `embeddings-batch`); it was refused as one over-long prompt.
|
||||||
|
- Done: `query_prefix` on an embedding model, before questions only.
|
||||||
|
- Qwen3-Embedding-4B on the RTX 6000 (2026-10-09): llama.cpp Q8 1,575
|
||||||
|
tok/s as first set up (32k cache, 16 slots), 2,700 with a 16k cache,
|
||||||
|
FlashAttention and ubatch 1–2k; f16 no better. vLLM fp16 (`--runner
|
||||||
|
pooling`) 6,200 direct, 4,700–4,900 through the gateway in the import,
|
||||||
|
and its vectors match llama.cpp's (cosine 0.9995). The 103,579 chunks
|
||||||
|
took 3 h. Done (2026-10-10, branch `admission-bulk`): a source per
|
||||||
|
profile, so an embedding model's GPU profile runs vLLM and its CPU
|
||||||
|
profile llama.cpp; a band's own `queue_ttl` for bulk imports, and
|
||||||
|
`Retry-After` on a busy refusal, which is what had turned passages away
|
||||||
|
(llmgw-kb retries only what says when to).
|
||||||
|
- Retrieval on 40 questions written from the corpus, document in the top
|
||||||
|
5: bge-m3 31/40 (Turkish questions 13/21), Qwen3-Embedding-4B 37/40
|
||||||
|
(18/21), with its query prefix 36/40 and first place 31/40 instead of
|
||||||
|
28 (MRR 0.738 → 0.789 → 0.838). Next: translating the question with the
|
||||||
|
chat model, and a reranker.
|
||||||
|
|
||||||
|
## G. Jobs: 3D generation through ComfyUI (2026-10-10)
|
||||||
|
|
||||||
|
A request for a 3D model takes minutes on the one 24 GB card, and the card has to be
|
||||||
|
emptied of the chat model first. That is not a request to hold a socket for
|
||||||
|
— `internal/router/activate.go:46` already says so — it is a job: queued,
|
||||||
|
answered with an id, asked after, its result downloaded. The job machinery
|
||||||
|
is built first and tested without a GPU; the ComfyUI generalisation and the
|
||||||
|
custom-node install come after. Decided 2026-10-10: a `jobs` table of its
|
||||||
|
own rather than a one-line batch (the JSONL shape would be artificial and
|
||||||
|
has nowhere for progress), and progress is shown, not just a status.
|
||||||
|
|
||||||
|
Which models (decided 2026-10-10): the assets go into a game sold on
|
||||||
|
Steam, worldwide, the EU included, so the licence of the output decides
|
||||||
|
before the quality does. Hunyuan3D (every version) is out: Tencent's
|
||||||
|
licence excludes the EU, UK and South Korea, and whether a game shipped
|
||||||
|
there with its outputs is "use in the Territory" is not a question to
|
||||||
|
leave to a court. In, in this order: **TRELLIS.2** shape (MIT; the best
|
||||||
|
geometry; its own texture baking goes through nvdiffrec and nvdiffrast,
|
||||||
|
both under NVIDIA's non-commercial source licence, so that stage is not
|
||||||
|
run), **Step1X-3D** (Apache 2.0, StepFun; shape and texture in one, the
|
||||||
|
EU-safe textured path — its ComfyUI node and VRAM need are to be
|
||||||
|
checked), **Direct3D-S2** (MIT, DreamTech; high-detail shape, has a
|
||||||
|
ComfyUI node) if TRELLIS.2's detail falls short. Not in: TripoSR and
|
||||||
|
InstantMesh (permissive but superseded), Stable Fast 3D (Stability's
|
||||||
|
community licence, revenue-capped; served all the same since 2026-10-10
|
||||||
|
at the user's request, see G3), SAM 3D Objects (Meta's own licence,
|
||||||
|
to be read before it is considered). Steam's disclosure form (changed
|
||||||
|
2026-01-16) lists pre-generated 3D models as content that must be
|
||||||
|
declared on the store page: the licence notice G3 writes into the GLB is
|
||||||
|
what the studio answers that form from.
|
||||||
|
|
||||||
|
### G1. Jobs: store, worker, endpoints (M) — done 2026-10-10 (branch `jobs-g1`)
|
||||||
|
Built as written, with two choices: a job found running after its gateway
|
||||||
|
died is run again from the start (the prompt id column is there for G2 to
|
||||||
|
follow the same work instead), and the request is validated at the door
|
||||||
|
only as far as costs nothing — key, model, allowlist, the request's shape
|
||||||
|
— with limits, guardrails and admission applied when the worker replays it,
|
||||||
|
said as the job's error. The result file's `content_type` is the job's; the
|
||||||
|
files endpoint still serves every file as an octet stream.
|
||||||
|
- Store: `jobs` (id, owner key/principal, kind, model, status, input
|
||||||
|
(JSON), progress (JSON: queue position, percent, step), result file id,
|
||||||
|
error, created/started/finished, lease owner/until), migrations for
|
||||||
|
sqlite and postgres beside `internal/store/batches.go`, whose
|
||||||
|
claim/renew/release lease pattern is copied, not shared: a job is one
|
||||||
|
unit of work, a batch is many lines.
|
||||||
|
- Worker: `internal/router/jobworker.go` after `batchworker.go`. Any
|
||||||
|
gateway on the store claims a queued job, asks for the model with
|
||||||
|
`activator.Want` and waits as long as it takes (no `ActivationWait`
|
||||||
|
bound: nobody is holding a connection), runs it, stores the output
|
||||||
|
through the files store, writes usage and price, marks it done. A
|
||||||
|
gateway that dies mid-job loses its lease and another one picks it up;
|
||||||
|
the ComfyUI prompt id is in the job row so the second gateway asks
|
||||||
|
after the same work rather than queueing it again.
|
||||||
|
- Endpoints: `POST /v1/3d/generations` validates like any inference
|
||||||
|
request (key, allowlist, limits, admission, guardrails on the prompt),
|
||||||
|
writes the job, answers 202 with `{id, status: "queued"}`;
|
||||||
|
`GET /v1/3d/generations/{id}` returns status, progress and, when done,
|
||||||
|
`output_file_id` and the usage; `POST .../{id}/cancel`; the file comes
|
||||||
|
from `GET /v1/files/{id}/content`. Owner-scoped like batches. Results
|
||||||
|
are kept for the batch files' retention and deleted with them: a
|
||||||
|
departure from `images.go`'s "kept nowhere", because a mesh is a
|
||||||
|
download, not a JSON field; the retention is the bound.
|
||||||
|
- Done when: a job survives the gateway that took it being killed, a
|
||||||
|
cancelled job leaves ComfyUI's queue (`comfy.Client.cancel`), two
|
||||||
|
gateways never both run one job (test with the lease), and the usage
|
||||||
|
row carries the job's price.
|
||||||
|
|
||||||
|
### G2. Progress from ComfyUI (S, after G1) — done 2026-10-10 (branch `jobs-3d`)
|
||||||
|
Built as written: the gate's websocket under its own client id, the
|
||||||
|
client_id of every /prompt rewritten, /progress/{prompt_id} on the gate,
|
||||||
|
the router reading it and /queue into the job (only into a job of the same
|
||||||
|
key). Left: a node that reports no steps (TripoSR's) shows its name and no
|
||||||
|
percent; a vanished replica is said by the job failing, not by progress.
|
||||||
|
The gate (`internal/comfy/gate.go`) passes seven paths and no websocket.
|
||||||
|
Rather than open `/ws`, the gate subscribes to ComfyUI's own `/ws` once,
|
||||||
|
with `golang.org/x/net/websocket` (already a dependency), under a client
|
||||||
|
id of its own, and rewrites the `client_id` of every `/prompt` it
|
||||||
|
forwards to that one. It keeps the last `executing`, `progress`
|
||||||
|
(value/max/node) and `execution_error` message per prompt id and serves
|
||||||
|
them at `GET /progress/{prompt_id}` on the gate; the router polls that
|
||||||
|
beside `/history` and `/queue` and writes queue position, percent and the
|
||||||
|
current node's class into the job's progress. The gate's buffer is
|
||||||
|
bounded (the last N prompts) and the websocket reconnects. Done when a
|
||||||
|
3D job shows "queued, 2 ahead" then "running 40%, step 12/30" in
|
||||||
|
`ctl jobs` and on the account page, and a job whose replica vanished
|
||||||
|
says so within one poll.
|
||||||
|
|
||||||
|
### G3. ComfyUI beyond pictures: 3D models and custom nodes (M, after G1) — done 2026-10-10 (branch `jobs-3d`), with TripoSR
|
||||||
|
Built: output kinds, image upload through the gate, every output list
|
||||||
|
fetched, OBJ to GLB, licence in /v1/models, the job and the GLB, per_mesh
|
||||||
|
and usage.meshes (migration 0045), custom nodes cloned at their commit by
|
||||||
|
the gate with packages beside the replica (a node's named packages at its
|
||||||
|
range, the rest at ComfyUI's versions). Not as written: the model. On the
|
||||||
|
PoC's Turing card TRELLIS.2 cannot run — it needs Meta's gated DINOv3,
|
||||||
|
its published background remover (RMBG-2.0) is non-commercial, and its
|
||||||
|
sparse attention and Direct3D-S2's need flash-attention (Ampere and
|
||||||
|
newer). TripoSR (MIT) proves the chain on cuda01: a .glb from a picture
|
||||||
|
in about 30 s. Left: background removal before TripoSR (BiRefNet, MIT,
|
||||||
|
as a node), TRELLIS.2 or Direct3D-S2 on an Ampere card with an ungated
|
||||||
|
encoder, a missing node refusing the replica by name.
|
||||||
|
Added 2026-10-10 at the user's request, beside the order above: **Stable
|
||||||
|
Fast 3D** as `sf3d` (Stability AI Community License: free under US$1 M
|
||||||
|
annual revenue, an enterprise licence above, "Powered by Stability AI"),
|
||||||
|
the textured mesh that runs on Turing: about 1 s a mesh warm in fp16,
|
||||||
|
UV-mapped GLB with its texture, 18 GB of the card at peak. Its weights are
|
||||||
|
gated, so they were fetched once with the user's token into
|
||||||
|
/srv/llmgw-comfy/stable-fast-3d and linked into the engine user's
|
||||||
|
Hugging Face cache, which its loader node reads by repository name; no
|
||||||
|
token is on the machine at runtime. Its own save node returns the GLB as
|
||||||
|
base64, which the gate cannot fetch, so `LLMGWSaveMesh` writes the file;
|
||||||
|
`LLMGWPrepareForMesh` now also gives the object's mask. Left: its two
|
||||||
|
CUDA extensions (texture_baker, uv_unwrapper) are not on PyPI, so they
|
||||||
|
were built on cuda01 and installed into the engine's venv by hand; a
|
||||||
|
custom node's requirements need a way to name a local build, or the
|
||||||
|
engine install must carry them, before a customer site gets this. And
|
||||||
|
rembg's default session now fetches BRIA RMBG-2.0 (non-commercial):
|
||||||
|
every workflow must name its rembg model (ours say u2net).
|
||||||
|
- Catalog: a model makes `pictures` or `meshes` (`MakesImages` becomes an
|
||||||
|
output kind; `ServesComfyUI` stays). 3D weight folders in the gate's
|
||||||
|
`modelFolders`; the workflow's placeholders gain `image` (the input
|
||||||
|
picture, base64 in the request, written to the replica's `input/` by
|
||||||
|
the gate through a new `POST /upload/image` the gate allows — or a
|
||||||
|
base64-loading node, whichever TRELLIS.2's wrapper wants).
|
||||||
|
- `comfy.Client.fetch` reads every output key, not `images` only, and
|
||||||
|
fetches each file named by `/view`; `Image` becomes `Artifact` with its
|
||||||
|
content type (model/gltf-binary, application/octet-stream for .obj).
|
||||||
|
- Custom nodes: a model (or the engine config) names git repositories
|
||||||
|
and a pip requirements list; the agent's provision step clones them
|
||||||
|
into the replica's `custom_nodes/` (the gate's `--base-directory` is
|
||||||
|
where ComfyUI looks, not the install directory) and installs the
|
||||||
|
requirements into the engine's venv with the same PyTorch index and
|
||||||
|
lock discipline as A8. A replica with a missing node refuses to start
|
||||||
|
with the node's name in the error rather than ComfyUI's traceback.
|
||||||
|
- Licence of the output (decided 2026-10-10: it matters). A catalog model
|
||||||
|
carries `license: {name, url, notice}`; a job's answer, `/v1/models`
|
||||||
|
and the portal page show it beside the result, and the mesh's GLB
|
||||||
|
`asset.extras` carries the notice too, since the file travels without
|
||||||
|
the API. What was checked on 2026-10-10: TRELLIS.2 weights are MIT and
|
||||||
|
its outputs free, but its texture baking depends on nvdiffrast, whose
|
||||||
|
NVIDIA Source Code License is non-commercial — a commercial service
|
||||||
|
must render textures another way or skip that stage. Hunyuan3D 2.0 and
|
||||||
|
2.1 claim no rights in outputs, but the licence excludes the EU, UK and
|
||||||
|
South Korea, needs Tencent's permission above 1 M monthly active users,
|
||||||
|
forbids using outputs to train other models, and asks for "Powered by
|
||||||
|
Tencent Hunyuan"; the notice ships with any distribution. TripoSR is
|
||||||
|
MIT, InstantMesh Apache 2.0. Every one of them trained on Objaverse,
|
||||||
|
some of whose objects are CC-BY-NC: a provenance question no licence
|
||||||
|
text settles, to be said in the notice rather than hidden.
|
||||||
|
- Pricing: `per_image` stays; `per_mesh` beside it in `accounting.Price`,
|
||||||
|
`Outcome.Meshes`, the usage sums and exports (as eaea671 did for
|
||||||
|
pictures).
|
||||||
|
- Done when: TRELLIS.2 on cuda01 with `scaling: {min: 0,
|
||||||
|
idle_timeout: 10m}` makes a .glb from a picture through G1's endpoint
|
||||||
|
with nvdiffrast and nvdiffrec not installed in its venv (the licence
|
||||||
|
is kept by the install, not by a note), the GLB carries the MIT
|
||||||
|
notice,
|
||||||
|
the card is free again after the idle timeout, and the same request
|
||||||
|
against flux-schnell is refused as "makes pictures, not meshes".
|
||||||
|
|
||||||
|
### G4. Portal and console (S, after G2) — done 2026-10-10 (branch `jobs-3d`)
|
||||||
|
Built: /3d with model-viewer served from the gateway, progress, download
|
||||||
|
and licence; `ctl 3d list|get|cancel` over /api/v1/client-jobs (ctl jobs
|
||||||
|
was taken by the control plane's own work). Left: the console's Jobs view.
|
||||||
|
A 3D page beside `/images` (upload a picture, watch progress, download
|
||||||
|
the .glb, a viewer with `<model-viewer>` from the allowed CDNs), `ctl
|
||||||
|
jobs list|get|cancel`, the console's Jobs view. The page sends down the
|
||||||
|
same path as the API, like `imagesSend` does.
|
||||||
|
|
||||||
|
## H. ComfyUI as a platform (written 2026-10-11)
|
||||||
|
|
||||||
|
G made one model at a time work: flux for pictures, TripoSR and SF3D for
|
||||||
|
meshes, each with its own endpoint, its own request shape and its own
|
||||||
|
replica. The user's direction (2026-10-11): llmgw is a generic platform,
|
||||||
|
and should serve whatever ComfyUI can run — pictures, edits, video,
|
||||||
|
audio, meshes, upscaling, segmentation — as data in the catalog rather
|
||||||
|
than code per model. Two decisions shape everything below:
|
||||||
|
|
||||||
|
- **The licence is metadata, not a filter.** llmgw does not leave a
|
||||||
|
model out because of its licence. The licence travels with the model
|
||||||
|
and with every file it makes; what a site will not serve is that
|
||||||
|
site's policy (H6). The game studio's EU constraint is one such policy,
|
||||||
|
not a decision the code makes for everyone.
|
||||||
|
- **The install fits the hardware.** The PoC has one Turing card; other
|
||||||
|
sites will have Ampere, Ada and Hopper. A catalog entry must be
|
||||||
|
writable once and placed sensibly anywhere: fp16 on Turing, bf16 or
|
||||||
|
fp8 where the card has them, flash-attention models only where it
|
||||||
|
runs, CUDA extensions built for the card that runs them (H2, H4).
|
||||||
|
|
||||||
|
The first set of models proves the structure at its corners rather than
|
||||||
|
by licence: Qwen-Image-Edit (several weight files, two pictures in),
|
||||||
|
Wan 2.2 small (video out, a long job), ACE-Step (audio out), SF3D (a
|
||||||
|
package not on PyPI, gated weights), Hunyuan3D (a licence that excludes
|
||||||
|
the EU, which H6 must be able to say no to).
|
||||||
|
|
||||||
|
### H1. A workflow says what it takes and makes (M) — built 2026-10-11, not yet tried on the PoC
|
||||||
|
Built as written, with three choices: a run answers with one file, the
|
||||||
|
first of its output kind (several outputs as one zip is left); the
|
||||||
|
workflow's placeholders are checked against the inputs when a run is
|
||||||
|
served, since the catalog never reads the workflow file; and video,
|
||||||
|
audio and files count as nothing in usage until a per-run price exists
|
||||||
|
(per_image and per_mesh still count pictures and meshes).
|
||||||
|
- Catalog: `comfyui.inputs` — a list of `{name, type, required, default,
|
||||||
|
min, max, choices, max_mb, doc}`, type one of text, int, float, bool,
|
||||||
|
choice, image, mask, audio, video, file. Each is a placeholder of the
|
||||||
|
same name in the workflow. The template's own placeholders are checked
|
||||||
|
against the list when the catalog is built: one the workflow uses that
|
||||||
|
no input or built-in names is refused there, not at the first request.
|
||||||
|
- `comfyui.output` gains `video`, `audio` and `files` beside `pictures`
|
||||||
|
and `meshes`; `files` takes whatever the workflow saves. `comfy.Image`
|
||||||
|
becomes `comfy.File` and `typeByName` knows mp4, webm, gif, wav, flac,
|
||||||
|
mp3, ogg.
|
||||||
|
- Endpoint: `POST /v1/workflows/runs` with `{model, inputs: {...}}`, a
|
||||||
|
job like G1's (`GET /v1/workflows/runs[/{id}]`, `.../cancel`). A file
|
||||||
|
input is base64, a data URL, or a `file-…` id from `/v1/files` for
|
||||||
|
what is too big to send inline (video, long audio). Files go into the
|
||||||
|
replica's input directory through the gate's `/upload/image`, which
|
||||||
|
ComfyUI uses for any file. `/v1/images/generations` and
|
||||||
|
`/v1/3d/generations` stay as they are, as fixed shapes over the same
|
||||||
|
machinery.
|
||||||
|
- The models list (`/v1/models`) gives each ComfyUI model its inputs and
|
||||||
|
output kind, so a client can build a form without reading the catalog.
|
||||||
|
- Done when: the existing flux, triposr and sf3d entries work unchanged,
|
||||||
|
and one model with a picture and a text input (Qwen-Image-Edit) runs
|
||||||
|
through `/v1/workflows/runs` with a 400 for a missing required input
|
||||||
|
before any job is written.
|
||||||
|
|
||||||
|
### H2. Profiles that fit the hardware (M) — built 2026-10-11
|
||||||
|
Built as written. A profile by need is left out of placement until the
|
||||||
|
cluster's cards are known: with no class, placement would have read it as
|
||||||
|
a CPU profile. A profile's own `source` is loaded under its own file name
|
||||||
|
by the workflow's `{{checkpoint}}`.
|
||||||
|
- A profile may say `gpu: {min_compute_capability, min_memory_gb}` in
|
||||||
|
place of `gpu_class`. When the reconciler places a model, such a
|
||||||
|
profile stands for every GPU class in the cluster that meets it, at
|
||||||
|
the profile's priority: the catalog function that turns profiles into
|
||||||
|
placement's view takes the cluster's classes (class, compute
|
||||||
|
capability, memory) and returns one concrete profile per class that
|
||||||
|
fits, named `<profile>@<class>`. Placement itself is unchanged: it
|
||||||
|
still sees exact classes, and a tensor-parallel group still never
|
||||||
|
mixes them.
|
||||||
|
- A ComfyUI profile may carry its own `comfyui` block (workflow, nodes,
|
||||||
|
checkpoint) over the model's: the fp8 workflow on compute 8.9 and up,
|
||||||
|
the fp16 one on 7.5. A Turing card is not an error, it is the profile
|
||||||
|
that fits it.
|
||||||
|
- Done when: one catalog entry with an "ampere+" profile (min 8.0) and a
|
||||||
|
"turing" fallback places the fallback on cuda01, and the same entry,
|
||||||
|
in a test with an A100 in the inventory, places the other.
|
||||||
|
|
||||||
|
### H3. Environments: which models can share a replica (S, the catalog side)
|
||||||
|
- `comfyui.environment: <name>` puts a model in a named environment.
|
||||||
|
Models in one environment must agree on every custom node they both
|
||||||
|
name (same repository, same commit) and their requirements must
|
||||||
|
resolve together; the catalog refuses an environment that cannot, with
|
||||||
|
both models' names. Models with no environment are each their own, as
|
||||||
|
today.
|
||||||
|
- `Catalog.Environments()` returns each environment with its models and
|
||||||
|
the union of their nodes: what one replica of it must install.
|
||||||
|
- Placement and eviction of a replica that serves every model of its
|
||||||
|
environment (so flux, SF3D and Qwen-Image-Edit stop handing the card to
|
||||||
|
each other with a 30 s cold start each time) is the other session's
|
||||||
|
work (llmgw-b7), built on this.
|
||||||
|
|
||||||
|
### H4. Custom nodes with packages that are built, not downloaded (S) — built 2026-10-11
|
||||||
|
Built as written, with `uv build --no-build-isolation` against ComfyUI's
|
||||||
|
Python, the card's capability asked of torch in the replica (so
|
||||||
|
CUDA_VISIBLE_DEVICES is the card it will run on), and wheels kept under
|
||||||
|
the engines' cache by commit, `sm<arch>` and directory. Also
|
||||||
|
`comfyui.huggingface_repo`: a node that loads its weights by repository
|
||||||
|
name (SF3D's) finds them in the engines' Hugging Face cache, linked by the
|
||||||
|
gate, so no token is on the node.
|
||||||
|
- `nodes[].build: [subdir, ...]`: directories of the node's repository
|
||||||
|
that are Python packages with C++/CUDA extensions (SF3D's texture_baker
|
||||||
|
and uv_unwrapper). The gate builds each as a wheel with the engine's
|
||||||
|
Python, `TORCH_CUDA_ARCH_LIST` set from the compute capability of the
|
||||||
|
card it runs on, and installs it into the replica's own `site` like
|
||||||
|
any other requirement. Built wheels are kept under the engine's home by
|
||||||
|
node commit, subdirectory and architecture, so the second replica on
|
||||||
|
the same kind of card does not build again.
|
||||||
|
- A node needing nvcc on a node without it refuses the replica with that
|
||||||
|
said, not with a compiler's traceback.
|
||||||
|
- Done when: SF3D runs on cuda01 with nothing installed into the engine's
|
||||||
|
venv by hand (the hand install of 2026-10-10 removed first).
|
||||||
|
|
||||||
|
### H5. Licence on every output (S) — done 2026-10-14: the whole licence in GLB asset.extras and PNG iTXt; video and audio not yet
|
||||||
|
- `License` gains `id` (SPDX where there is one, else a lowercase name:
|
||||||
|
stability-community, tencent-hunyuan-community), `commercial` (unset is
|
||||||
|
not said), `excluded_regions` (ISO 3166 codes, `EU` for the union),
|
||||||
|
`revenue_cap_usd` and `attribution` ("Powered by Stability AI"). All
|
||||||
|
optional but the name. Done 2026-10-11 in the catalog, so H6 can match
|
||||||
|
on `id`.
|
||||||
|
- Written into the file where the format has a place for it: glTF
|
||||||
|
`asset.extras.license` (as now), PNG an `iTXt` chunk named `license`
|
||||||
|
(the workflow's own text chunks are still stripped), MP4/WebM and the
|
||||||
|
audio formats in their metadata where ffmpeg is at hand on the router,
|
||||||
|
otherwise beside it in the job's answer only — said in the docs.
|
||||||
|
|
||||||
|
### H6. Licence choices (S) — done 2026-10-11 (4bbac08), live on the PoC
|
||||||
|
The user asked (2026-10-11) for the choice to be a person's: on their
|
||||||
|
profile they pick the licences they will not use, and their keys and
|
||||||
|
portal session neither see those models in `/v1/models` nor may call
|
||||||
|
them (403, saying which licence). Matched on `license.id`, and on
|
||||||
|
`commercial` / `excluded_regions` where a choice is put that way. Its
|
||||||
|
store table is migration 0046. A site-wide or team-wide default can sit
|
||||||
|
on top of it later.
|
||||||
|
|
||||||
|
### H7. A job worker per model, not one for all (S) — done 2026-10-14: one job per model at a time, eight in all
|
||||||
|
The worker runs one job at a time: a job waiting for the card holds up a
|
||||||
|
job for another model that could run now (seen on the PoC, 2026-10-10).
|
||||||
|
Jobs are claimed and run concurrently up to a limit per model (or per
|
||||||
|
environment, after H3) and a cluster-wide cap, with the lease and
|
||||||
|
leader-only rules of G1 kept.
|
||||||
|
|
||||||
|
### H8. Presets that add themselves (S) — done 2026-10-13 (v0.14.5)
|
||||||
|
flux-schnell and triposr come with llmgw (`ctl model add <preset>`), and
|
||||||
|
the leader adds them on demand where a node fits one, the cluster installs
|
||||||
|
ComfyUI and the catalog makes nothing of that kind (`presets.auto`); a
|
||||||
|
preset removed is declined and not added back. The cluster page and the
|
||||||
|
console show and add them.
|
||||||
|
|
||||||
|
### H9. Small things learned on the PoC (2026-10-14)
|
||||||
|
- Done: `albedo_match` on sf3d (its delighting made dark objects light);
|
||||||
|
`on_demand.evict_after_idle` / `min_lifetime` as live settings; an
|
||||||
|
upgrade that leaves nodes behind says which; `ctl model serve` serves an
|
||||||
|
embedding model for embedding; refreshes wait while a form is filled in.
|
||||||
|
- Done 2026-10-15: llmgw's `/mcp` with picture, 3D, VLM and workflow tools,
|
||||||
|
so a client needs no server of its own (asked by the game team); the
|
||||||
|
shared ComfyUI replica (H3's placement side).
|
||||||
|
- Done 2026-10-16 (v0.14.6, v0.14.7): setup lists every part with what it
|
||||||
|
is and its settings; three machines each run the control plane, with a
|
||||||
|
floating address (VRRP id from the address); a lost store primary costs no
|
||||||
|
request (3 s connect per member, keepalive and TCP_USER_TIMEOUT, Patroni
|
||||||
|
ttl 20, a standby leaves the lease alone a round after the store returns);
|
||||||
|
a new leader waits for its judge within the budget; a judge is asked again
|
||||||
|
on another machine at half its budget; init sizes the judge (2 GB, no
|
||||||
|
prompt cache, two cores, one judge on up to four cores on one machine).
|
||||||
|
|
||||||
|
## I. What other gateways do that llmgw does not (written 2026-10-16)
|
||||||
|
|
||||||
|
From a comparison with Bifrost (maximhq/bifrost, Apache 2.0, Go), with
|
||||||
|
LiteLLM and Portkey in mind. Bifrost routes to 23+ cloud providers and runs
|
||||||
|
no model; llmgw runs the models, so most of Bifrost is either in llmgw
|
||||||
|
already (keys, budgets, fallbacks, guardrails, tracing, SSO, HA — the last
|
||||||
|
three paid in Bifrost) or does not apply. These four are worth taking for
|
||||||
|
a site that runs its own models. Left until a customer asks: native
|
||||||
|
Azure/Anthropic/Bedrock/Vertex adapters, request hooks a customer plugs in,
|
||||||
|
routing by what a request holds. Not taken: a realtime (WebSocket audio)
|
||||||
|
API, prompt management, an `npx` install.
|
||||||
|
|
||||||
|
### I1. Answers to similar questions, not only the same one (M) — done 2026-10-25
|
||||||
|
`keys cache --similar`, `router.cache.embedding_model`, `internal/router/similar.go`. Changed
|
||||||
|
from the plan: the vectors are in the gateway's memory beside the exact answers, not a pgvector
|
||||||
|
table. The exact cache keeps answer text out of the store on purpose (data.md), and a table of
|
||||||
|
answers would undo that; it also works on SQLite. The usage row marks a similar hit as a hit
|
||||||
|
(no column of its own). Open: the 40-rewordings and eval-suite runs on the PoC.
|
||||||
|
The original item follows.
|
||||||
|
The answer cache (`internal/router/cache.go`, C17) answers a request whose
|
||||||
|
body is the same byte for byte. A help desk asked "how do I reset my
|
||||||
|
password" in forty wordings asks the model forty times.
|
||||||
|
- Per project, off by default, beside the exact cache: `llmgw keys cache
|
||||||
|
--project P --similar 0.95` (the cosine similarity a question must reach;
|
||||||
|
higher is stricter). The exact cache is asked first.
|
||||||
|
- The question embedded by a model the operator names
|
||||||
|
(`router.cache.embedding_model`, an embedding model in the catalog), into a
|
||||||
|
pgvector table per project with the answer, the model and the TTL; the
|
||||||
|
store's halfvec and text index work from F applies. SQLite stores keep the
|
||||||
|
exact cache only.
|
||||||
|
- What counts as the question: the last user turn, with the system prompt
|
||||||
|
and the earlier turns hashed and required to match exactly — a similar
|
||||||
|
question in another conversation is not the same question. Tools,
|
||||||
|
`response_format` and the sampling settings also exact.
|
||||||
|
- Said: `x-llmgw-cache: similar` with the similarity, and the usage row
|
||||||
|
says so; guardrails judge the request before a hit, as for the exact
|
||||||
|
cache. A project with `no_store` gets neither.
|
||||||
|
- Done when: forty rewordings of one question on the PoC cost one model call
|
||||||
|
and 39 lookups, and an eval suite run with the cache on scores within a
|
||||||
|
point of the run with it off.
|
||||||
|
|
||||||
|
### I2. MCP gateway: the customer's tool servers behind llmgw (L)
|
||||||
|
P1 done 2026-10-20: HTTP servers registered (`ctl mcp`, `/api/v1/mcp/servers`,
|
||||||
|
store `mcp_servers`), tools as `server.tool` in `/mcp`, `allowed_tools`
|
||||||
|
grants in the tree (deny by default), credentials in the store or by
|
||||||
|
reference, the caller named in `X-Llmgw-*` headers, time and size bounds,
|
||||||
|
progress passed on, audit (Source `mcp`) and metrics. Left: P2, the
|
||||||
|
guardrails both ways, a per-tool rate limit, a breaker, and a portal page;
|
||||||
|
P3, stdio servers and per-person OAuth. The "done when" with Claude Code
|
||||||
|
against a real server is still to try.
|
||||||
|
`/mcp` serves llmgw's own tools. An agent inside a company also wants Jira,
|
||||||
|
the file server, a database — each an MCP server, each today configured
|
||||||
|
in every client with its own credentials and nobody seeing what was called.
|
||||||
|
- Servers registered once, by an administrator: `llmgw ctl mcp add jira
|
||||||
|
--url https://… --header-file /etc/llmgw/secrets/jira` (HTTP transport;
|
||||||
|
stdio servers run on the control plane under their own user, like an
|
||||||
|
engine). Their credentials stay on the control plane, never in a client.
|
||||||
|
- `/mcp` lists every server's tools, prefixed by its name (`jira.search`),
|
||||||
|
filtered by what the key may use: an allowlist per org, team, project or
|
||||||
|
key, by server or by tool, in the tenancy tree as model allowlists are.
|
||||||
|
- Each call goes to the audit log — who, which tool, the arguments' size,
|
||||||
|
the outcome and how long — and through the guardrails both ways (a tool's
|
||||||
|
answer is prompt input). A per-tool rate limit.
|
||||||
|
- Later, if it earns it: Bifrost's "code mode" (the tools as one typed API
|
||||||
|
the model writes code against), which cuts the tokens of a long tool list.
|
||||||
|
- Done when: Claude Code given only llmgw's `/mcp` reaches a test MCP server
|
||||||
|
through it, a key without the grant does not see the server's tools, and
|
||||||
|
every call is in `ctl audit`.
|
||||||
|
|
||||||
|
### I3. What llmgw adds to a request, measured (S) — done 2026-10-16
|
||||||
|
`llmgw ctl bench overhead` (`internal/cli/bench_overhead.go`): an instant
|
||||||
|
engine and the gateway in one process, four modes (direct, bare, auth, auth
|
||||||
|
and guard patterns) at rising rates. On an Apple M5 the gateway added
|
||||||
|
0.05–0.42 ms at p50 and at most 0.49 ms at p99, in the README.
|
||||||
|
`TestTheGatewaysOverheadIsMeasured` runs a second of it in CI with no bound
|
||||||
|
asserted. Left: the CPU figure is rough with everything in one process; a
|
||||||
|
gateway in a process of its own would measure it properly. The original
|
||||||
|
item follows.
|
||||||
|
|
||||||
|
Bifrost's headline is "11 µs at 5k RPS" (its own figure, and its own pages
|
||||||
|
disagree with it). llmgw has none, so any question about its overhead is
|
||||||
|
answered with a guess.
|
||||||
|
- `llmgw ctl bench --overhead`: the same load through the gateway and straight
|
||||||
|
at a `sim-engine` (`internal/cli/simengine.go`) that answers at once, at
|
||||||
|
rising request rates, with auth, accounting and the guardrails' pattern
|
||||||
|
checks on and off. Reports the added latency at p50/p99 and the gateway's
|
||||||
|
CPU per thousand requests.
|
||||||
|
- On the PoC the CPU is oversubscribed (pve1, 28 threads for ~125 vCPUs):
|
||||||
|
report it as a ratio and record the host's load beside it, and repeat on
|
||||||
|
an idle machine before a figure goes into the README.
|
||||||
|
- Done when: the README states the overhead, with how it was measured, and
|
||||||
|
CI runs the short version so a change that doubles it is seen.
|
||||||
|
|
||||||
|
### I4. Several keys for one outside provider (S) — done 2026-10-17
|
||||||
|
`external.api_key_files` (`catalog.ExternalKey`: file, weight), one endpoint
|
||||||
|
each in `control.externalEndpoints`, 401 retried on another key and
|
||||||
|
`external_key_refused` notified (`router.keyRefused`). With one key a 401 is
|
||||||
|
still the client's. `TestAKeyAtItsLimitOrRefusedIsPassedOverForAnother`. The
|
||||||
|
original item follows.
|
||||||
|
|
||||||
|
An external model has one `api_key_file` (`internal/control/control.go`,
|
||||||
|
where external endpoints are made). A provider's limit is per key; one key
|
||||||
|
answering 429 makes the whole fallback answer 429.
|
||||||
|
- `api_key_files: [a, b, c]`, each with an optional `weight`: one endpoint
|
||||||
|
per key (`external:<model>#<n>`), so the router's scoring, retries and
|
||||||
|
breakers work per key as they already do per replica. A key answering 429
|
||||||
|
or 401 is passed over while its breaker is open; a 401 also notifies
|
||||||
|
(`external_key_refused`).
|
||||||
|
- The usage row says which key answered (by its index, never the key).
|
||||||
|
- Done when: with two keys and one returning 429, every request is answered
|
||||||
|
by the other, and the console shows the one breaker open.
|
||||||
|
|
||||||
|
## J. Found testing HA and single-machine installs (written 2026-10-16)
|
||||||
|
|
||||||
|
Three VMs installed as HA from the setup screens, a fourth as a judge node,
|
||||||
|
a fifth as a single 4-core, 8 GB machine; machines powered off under load.
|
||||||
|
|
||||||
|
### J1. A restart refused for room another restart was giving back (S) — done 2026-10-16
|
||||||
|
Not a wait for ever, as first written: on the 4-core VM the judge's restart
|
||||||
|
was *refused* ("fits on no node even with this replica stopped") because the
|
||||||
|
starter, restarted at the same time to reserve fewer cores, still held its
|
||||||
|
old ones. Once refused, nothing tried again, so the judges kept their old
|
||||||
|
figures. Two fixes in `internal/control/reconciler`:
|
||||||
|
- a restart whose replacement fits once other restarts under way have gone
|
||||||
|
(queued, retiring or draining) waits for them, up to DrainGrace, instead
|
||||||
|
of being refused (`fitsAfterRestarts`, restart.go);
|
||||||
|
- a drained replica counts a retiring one that still answers as serving, so
|
||||||
|
one restart after another no longer leaves the first's drained replica,
|
||||||
|
and the room it holds, waiting out its grace (reconciler.go).
|
||||||
|
`TestARestartWaitsForRoomAnotherRestartWillGiveBack` is the 4-core case.
|
||||||
|
|
||||||
|
### J2. A judge the leader adds is sized as init's is (S)
|
||||||
|
When a policy names a judge the catalog lacks, the leader serves it itself
|
||||||
|
(`internal/cli/judgekeeper.go` via `knownJudge.serveRequest` in
|
||||||
|
`judges_known.go`, through modelserve). That judge reserves 3 GB (the
|
||||||
|
weights, an 8192-token KV cache and a fixed gigabyte), no cores, and runs
|
||||||
|
with llama.cpp's prompt cache, which grew one to 3.2 GB. init's judge
|
||||||
|
(`guardModel` in `internal/cli/serverinit_cluster.go`) reserves 2 GB and
|
||||||
|
its cores and runs with `--cache-ram 0`.
|
||||||
|
- `modelserve` gives a guard model `--cache-ram 0`, and the keeper's
|
||||||
|
request the same memory and cores rule as `cpuPlan`.
|
||||||
|
- Done when: a judge removed from the catalog comes back with the same
|
||||||
|
figures init would have given it.
|
||||||
|
|
||||||
|
### J3. HA setup puts the judges on nodes of their own (S)
|
||||||
|
Under load on a 4-core control plane that was also a node, 38 of 60
|
||||||
|
requests were refused for want of a judgment. With the judges on a node of
|
||||||
|
their own (4 cores, 32 GB, two cores each) the same test refused 2.
|
||||||
|
- The HA setup offers a judge node or two: machines given like the store's,
|
||||||
|
added as nodes with a label (`llmgw.role: judge`), and the judge's
|
||||||
|
profile selects that label.
|
||||||
|
- Done when: the setup installs the judges on the machines given for them,
|
||||||
|
and the load test above refuses no more than it did by hand.
|
||||||
|
|
||||||
|
### J4. The model mirror on more than one machine (M)
|
||||||
|
In an HA install the mirror stays on the first machine. While it is down,
|
||||||
|
models the nodes already have keep serving and new ones wait for it.
|
||||||
|
- A second mirror kept in step (each machine of the store, or one more), the
|
||||||
|
control planes and nodes given both, the first that answers used.
|
||||||
|
- Done when: with the first machine powered off, a model added then is
|
||||||
|
fetched and served.
|
||||||
|
|
||||||
|
### J5. An upgrade corrects an earlier install's judge (S) — done 2026-10-16
|
||||||
|
Built in the judge keeper (`internal/cli/judgekeeper.go`, `refit`), on the
|
||||||
|
leader: a judge whose entry is exactly what init before v0.14.7 wrote is
|
||||||
|
given 2 GB, `--cache-ram 0` and cores by init's rule (`refitCores`: up to
|
||||||
|
four for one judge, two each for more), through `modelsvc.SetProfiles`,
|
||||||
|
and its ready replicas are restarted (after J1). Its replica count is left
|
||||||
|
as it is. One that differs is left alone and said once in the log. The
|
||||||
|
original item follows.
|
||||||
|
|
||||||
|
v0.14.6 and earlier installed two judges reserving no cores or one, with
|
||||||
|
2 or 4 GB and the prompt cache on. On fewer than eight cores every judgment
|
||||||
|
missed its 5 s budget and every request was refused. v0.14.7's notes say
|
||||||
|
how to fix it by hand (`ctl model resources`, then `ctl restart`).
|
||||||
|
- On upgrade, or on `server init` run again, a judge whose profile is still
|
||||||
|
exactly what an earlier init wrote is moved to today's figures and
|
||||||
|
restarted (after J1). One changed by hand is left alone and named.
|
||||||
|
- Done when: a v0.14.6 single-machine install upgraded to it judges within
|
||||||
|
budget without anything typed.
|
||||||
|
|
||||||
|
### J6. Rate limits per process (note)
|
||||||
|
Rate limits are counted in each control plane's memory. A failover starts
|
||||||
|
every key's minute afresh. Documented in install.md; worth a shared count in
|
||||||
|
the store only if a customer's limits must hold across a failover.
|
||||||
|
|
||||||
|
### J7. nginx in front of several control planes (S)
|
||||||
|
`deploy/proxy/Caddyfile` sends to whichever control plane answers `/leader`.
|
||||||
|
nginx's open-source build has no active health checks, so its `upstream`
|
||||||
|
block would send to standbys and to a lost leader alike.
|
||||||
|
- nginx.conf: the floating address as its one upstream, and why; or the
|
||||||
|
`max_fails` passive approach with what it costs.
|
||||||
|
|
||||||
|
### J8. A judge budget that fits the machine (S)
|
||||||
|
A judgment takes 2.3 s on four cores and 4.2 on two, against the 5 s
|
||||||
|
default: two cores are close to it, and a busy machine passes it.
|
||||||
|
- At install, after the judge is up, time three judgments. If the slowest is
|
||||||
|
more than half the budget, say so in the plan's closing text with the
|
||||||
|
choices (more cores, a GPU judge, a longer `latency_budget`, `fail:
|
||||||
|
open`). The budget itself is not changed without being asked.
|
||||||
|
- Done when: an install on a machine too slow for its judge says so before
|
||||||
|
its first refused request.
|
||||||
|
|
||||||
|
## L. Taken from Kong's AI Gateway (written 2026-10-25)
|
||||||
|
|
||||||
|
From a look at Kong AI Gateway 3.9–3.16 and 2.x. Kong's open source stops at
|
||||||
|
3.9, and only six of its AI plugins are open (proxy, prompt template,
|
||||||
|
decorator, regex prompt guard, request and response transformer); the rest
|
||||||
|
is Enterprise or Konnect. So nothing of Kong is embedded: the two meet over
|
||||||
|
plain OpenAI/Anthropic HTTP, and these are the ideas worth taking. Not taken:
|
||||||
|
prompt compression (Kong ships it as a private LLMLingua sidecar, its benefit
|
||||||
|
unshown), prompt templates and decorators (no one has asked), Konnect's
|
||||||
|
entity model. MCP items are llmgw-80's (I2); they are listed for it.
|
||||||
|
|
||||||
|
### L1. Traces in the OpenTelemetry GenAI conventions (S) — done 2026-10-25
|
||||||
|
`gen_ai.*` on the `inference` and `upstream` spans (`internal/router/trace.go`):
|
||||||
|
operation, request and response model, max_tokens, temperature, top_p, the
|
||||||
|
session as conversation id, input/output/cached tokens, the engine as
|
||||||
|
provider. Never prompt or answer text (`TestTracesSpeakTheGenAIConventionsAndHoldNoText`).
|
||||||
|
Prometheus already counted tokens by direction, cached apart. Left: the MCP
|
||||||
|
calls' spans as `gen_ai.tool.*`, once /mcp is traced at all.
|
||||||
|
|
||||||
|
### L2. Redacted personal data put back in the answer (S) — done 2026-10-25
|
||||||
|
`restore: true` (`guard.Rules.Restore`), `Verdict.Restore`, `internal/router/restore.go`
|
||||||
|
ahead of the output filter. The original item follows.
|
||||||
|
The guardrails rewrite personal data as `[KIND_n]` (`internal/guard/gate.go`,
|
||||||
|
`placeholders`), the same value always the same placeholder. A model that
|
||||||
|
answers "Dear [PERSON_1]" leaves it so. Kong's sanitizer restores.
|
||||||
|
- Per policy, `restore: true`: the placeholders of one request kept in memory
|
||||||
|
for its life, and swapped back in the answer, streamed too (a placeholder
|
||||||
|
split across two chunks held until it is whole).
|
||||||
|
- Only into the answer to the request that held them; never stored, never in
|
||||||
|
usage rows or traces.
|
||||||
|
- Done when: "write to Ayşe Yılmaz at ayse@example.com" through an enforce
|
||||||
|
policy reaches the model as placeholders and the client reads the real name
|
||||||
|
and address back.
|
||||||
|
|
||||||
|
### L3. A guardrail that is someone else's service (S) — done 2026-10-25
|
||||||
|
`guard.Service` (`internal/guard/service.go`), policy `services`, asked
|
||||||
|
before the judge on input and once on the whole answer. The original item follows.
|
||||||
|
Customers may be required to use one: Azure Content Safety, Lakera, NeMo
|
||||||
|
Guardrails, an in-house classifier. Kong's AI Custom Guardrail is a
|
||||||
|
templated HTTP call with an expression for "block".
|
||||||
|
- A detector kind `http`: URL, headers from secret files, a body template
|
||||||
|
with the text, a JSON path that says blocked and one that says why;
|
||||||
|
timeout and `fail` as the judge's. Checked on input, output or both.
|
||||||
|
- Done when: a policy with an `http` detector against a test service blocks
|
||||||
|
what it says to, and a service that does not answer is treated as `fail`
|
||||||
|
says.
|
||||||
|
|
||||||
|
### L4. Routing by latency and by cost, and failover by priority (M) — latency done 2026-10-25
|
||||||
|
`endpoint.Registry.ObserveTTFT`, `scheduler.Latency()` in `chat`. Failover in tiers is
|
||||||
|
what `fallbacks` already does (local, then an outside model when no room). Left:
|
||||||
|
routing by cost between several outside providers. The original item follows.
|
||||||
|
The scorers (`internal/router/scheduler/scorers.go`) know sessions, queues
|
||||||
|
and KV cache. Kong also balances by measured latency, by tokens or cost
|
||||||
|
used, and in priority tiers.
|
||||||
|
- A `latency` scorer: a moving average of time to first token per endpoint.
|
||||||
|
- Fallbacks in tiers: local replicas first, then an outside provider, with
|
||||||
|
the tier said in the usage row.
|
||||||
|
- Done when: with two replicas of one model, one on a slower card, `latency`
|
||||||
|
sends most of the traffic to the faster and the slower still gets some.
|
||||||
|
|
||||||
|
### L5. Prices by kind and by cache (S–M) — done 2026-10-25
|
||||||
|
`cached_per_1k`, charged on the answer's `cached_tokens`. A budget in money was already
|
||||||
|
there (`keys limits --budget`). Not done: cache-write and audio/video token prices —
|
||||||
|
no engine llmgw runs reports them, and no provider it reaches is billed by them yet.
|
||||||
|
The original item follows.
|
||||||
|
`catalog` prices are per million input/output tokens and per picture/mesh.
|
||||||
|
Kong prices cached reads and cache writes apart, and audio, image and video
|
||||||
|
tokens apart, and limits by cost.
|
||||||
|
- Price fields for cached input and by modality; the usage row's cost from
|
||||||
|
them; a budget limit in money as well as tokens (`keys limits --cost`).
|
||||||
|
- Done when: a request with 90% cached prompt costs what the cached rate says.
|
||||||
|
|
||||||
|
### L6. REST APIs as MCP tools (M) — for llmgw-80, beside I2
|
||||||
|
Kong turns OpenAPI operations into MCP tools. A company with an internal
|
||||||
|
REST API and no MCP server could offer it to agents through `/mcp`:
|
||||||
|
`llmgw ctl mcp add hr --openapi https://hr.internal/openapi.json`, each
|
||||||
|
operation a tool, under the same grants, audit, guardrails and rate as I2.
|
||||||
|
|
||||||
|
### L7. MCP sign-in by the MCP spec (M) — for llmgw-80, part of I2 P3
|
||||||
|
Kong's MCP OAuth2: llmgw's `/mcp` as an OAuth 2.1 resource server (protected
|
||||||
|
resource metadata, tokens checked by JWKS against the company's identity
|
||||||
|
provider), and a token exchanged for a short-lived one per upstream server
|
||||||
|
rather than one long-lived secret.
|
||||||
|
|
||||||
|
### L8. A2A (S, when asked)
|
||||||
|
Agent-to-agent traffic through the gateway: agent cards' URLs rewritten to
|
||||||
|
llmgw's, calls audited and traced. The standard is young; wait for a
|
||||||
|
customer.
|
||||||
|
|
||||||
|
### L9. llmgw behind Kong, written down (S) — done 2026-10-25
|
||||||
|
docs/kong.md, tried with kong:3.9: plain route and ai-proxy, keys passed on;
|
||||||
|
Kong 3.9 counts no completion tokens of a stream. The original item follows.
|
||||||
|
A customer that already runs Kong puts llmgw behind it: `ai-proxy` with
|
||||||
|
`provider: openai` and `upstream_url` at llmgw (3.9, open source), or a plain
|
||||||
|
route. A page in docs/ with the configuration, tried against Kong 3.9 in a
|
||||||
|
container, and what Kong's token accounting sees.
|
||||||
|
|
||||||
## D. Facts worth knowing before starting
|
## D. Facts worth knowing before starting
|
||||||
|
|
||||||
- Admin API: `127.0.0.1:9090`, no auth, `ctlClient` in `internal/cli/ctl.go`.
|
- Admin API: `127.0.0.1:9090`, behind the token in
|
||||||
|
`/var/lib/llmgw/admin-token` (A5), `ctlClient` in `internal/cli/ctl.go`.
|
||||||
Metrics on `:9091` with `/prometheus/targets` beside them.
|
Metrics on `:9091` with `/prometheus/targets` beside them.
|
||||||
- Store: `internal/store`, migrations under `internal/store/migrations/{sqlite,postgres}`;
|
- Store: `internal/store`, migrations under `internal/store/migrations/{sqlite,postgres}`;
|
||||||
the PoC runs PostgreSQL under Patroni.
|
the PoC runs PostgreSQL under Patroni.
|
||||||
|
|||||||
+854
-3
@@ -18,13 +18,809 @@ is only released once its **exit criterion** is met, not when its code lands.
|
|||||||
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | 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.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
|
||||||
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
||||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released |
|
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves; v0.14.6: setup that explains every part and lets it be chosen, the gateway on all three HA machines; v0.14.7: the judge sized for its machine; v0.14.8: sign-in with Active Directory, several keys for one outside provider; v0.14.9: sign-in set up from the console, a node's console that shows the machine) |
|
||||||
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
|
| v0.15.0 | — | The company's MCP tools and VS Code through the gateway; guardrails that ask outside services and put redacted values back; answers to similar questions | released |
|
||||||
|
| v1.0.0 | 5 | What phase 5 has left: precise KV routing proven on vLLM, the block cost picker, defragmentation across NVLink domains, prefill/decode disaggregation | planned |
|
||||||
|
|
||||||
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
|
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
|
||||||
APIs change between minors without a deprecation period.
|
APIs change between minors without a deprecation period.
|
||||||
|
|
||||||
## Unreleased
|
## v0.15.0 — 2026-10-25
|
||||||
|
|
||||||
|
The company's tools and editors through the gateway, and guardrails that
|
||||||
|
can lean on others. Its own MCP servers sit behind llmgw's `/mcp` under the
|
||||||
|
guardrails, a rate and a breaker. VS Code gets the gateway's models from an
|
||||||
|
extension the gateway serves. A guardrail policy can ask an outside
|
||||||
|
service, and can put redacted values back in the answer. Chat goes to the
|
||||||
|
replica answering soonest. A similar question can get the answer already
|
||||||
|
given. A cached prompt can be priced apart. Traces follow the OpenTelemetry
|
||||||
|
GenAI conventions, and Kong is written down from trying it. Also an install
|
||||||
|
guide with every screen, at `/install`.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- Migrations 0049 (`mcp_servers`), 0050 (their rate and breaker) and 0051
|
||||||
|
(`projects.cache_similar`), applied when the control plane starts.
|
||||||
|
- `llmgw server init` now refuses a machine that would serve models without
|
||||||
|
the memory for them: the starter model and the judges need 12 GB on one
|
||||||
|
machine, where 8 GB was said before. `--no-model`, `--no-guard-model` or
|
||||||
|
`--no-node` make it fit. A running cluster is not checked again.
|
||||||
|
- Traces carry the GenAI conventions' `gen_ai.*` attributes beside the
|
||||||
|
`llmgw.*` ones, which stay; docs/install.md, "Tracing", lists them.
|
||||||
|
- Nothing else changes until it is turned on: `services` and `restore` in a
|
||||||
|
guardrail policy, `keys cache --similar` with
|
||||||
|
`router.cache.embedding_model`, `cached_per_1k` in a price.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Answers to similar questions.** `llmgw keys cache --similar 0.95` also
|
||||||
|
gives a chat question the answer already given to one like it in the same
|
||||||
|
conversation, compared by `router.cache.embedding_model`, as
|
||||||
|
`x-llmgw-cache: similar` with the similarity. Held in memory beside the
|
||||||
|
exact answers; nothing written to the store but the setting.
|
||||||
|
- **A cached prompt costs the cached rate.** `cached_per_1k` in a price
|
||||||
|
charges the prompt tokens an engine or provider read from its cache (the
|
||||||
|
answer's `cached_tokens`) at their own rate; without it they cost what any
|
||||||
|
prompt token does, as before.
|
||||||
|
- **Chat goes to the replica answering soonest.** The router keeps each
|
||||||
|
replica's time to first token, smoothed over its last answers, and the
|
||||||
|
`chat` profile scores by it beside the queue and the KV cache: of two
|
||||||
|
replicas of one model, the one on the slower card or the busier host gets
|
||||||
|
less, and a replica not yet measured is tried.
|
||||||
|
- **llmgw and Kong, written down** ([docs/kong.md](docs/kong.md)): Kong 3.9
|
||||||
|
in front of llmgw by a plain route or its `ai-proxy`, each team's llmgw
|
||||||
|
key passed on; Kong as a provider behind llmgw; and that Kong 3.9 counts
|
||||||
|
no completion tokens of a streamed answer, so bill from llmgw's usage.
|
||||||
|
- **A guardrail that is someone else's service.** A policy's `services`
|
||||||
|
asks Azure Content Safety, Lakera, NeMo or a company's own classifier over
|
||||||
|
HTTP: a body template, keys from files, dotted paths to where the answer
|
||||||
|
says unsafe and why, on input, output or both, block or flag, and the
|
||||||
|
policy's `fail` when it does not answer.
|
||||||
|
- **Redacted values put back in the answer.** `restore: true` on a guardrail
|
||||||
|
policy's input: the model reads `[EMAIL_1]`, the client reads its own
|
||||||
|
address in the reply, streamed or not. Only the request's own values, held
|
||||||
|
in memory for its life; the output rules read the answer as the client
|
||||||
|
will.
|
||||||
|
- **Traces in the OpenTelemetry GenAI conventions.** An inference request
|
||||||
|
and each attempt at an engine carry `gen_ai.operation.name`, the requested
|
||||||
|
and the answering model, `max_tokens`, `temperature` and `top_p` when set,
|
||||||
|
the session as `gen_ai.conversation.id`, and the input, output and cached
|
||||||
|
tokens, and an attempt the engine that answered as `gen_ai.provider.name`.
|
||||||
|
Tools that read the conventions show llmgw's calls as model calls with no
|
||||||
|
dashboard of their own. No prompt or answer text is ever in a span.
|
||||||
|
- **llmgw for VS Code.** An extension of llmgw's own, served by every gateway
|
||||||
|
at `/setup/vscode/llmgw.vsix` with that gateway's address in it, made from
|
||||||
|
the binary itself: plain JavaScript, no dependencies, no build, and so no
|
||||||
|
marketplace needed at a site with no internet. It offers the gateway's chat
|
||||||
|
models to VS Code's language-model API — the chat's model picker, agent
|
||||||
|
mode, and every extension that asks VS Code for a model — with the sizes
|
||||||
|
and the tool and vision capabilities the catalog says, streamed, with tool
|
||||||
|
calls and their results both ways. **VS Code** is a tool on the account
|
||||||
|
page's **Connect your editor**: a key, the one-line install, and an **Open
|
||||||
|
in VS Code** link that hands the key to the extension, which keeps it in
|
||||||
|
VS Code's secret storage. **llmgw: Sign In** opens that page with a state
|
||||||
|
of its own; a link without it is confirmed first, the gateway named.
|
||||||
|
- **An install guide with every screen, at `/install`.** From three bare
|
||||||
|
Ubuntu machines to a first answer: what each machine needs and how to
|
||||||
|
check it, the release fetched and checked, the setup screens one by one,
|
||||||
|
the system's state, and a first request with a key from the account page.
|
||||||
|
The screens are a real three-machine install, recorded and kept as
|
||||||
|
terminal frames — sharp at any size and a few kilobytes — with each
|
||||||
|
session played back on request; the administrator's password and every
|
||||||
|
key were masked before it was kept. `/about` says more of what llmgw does
|
||||||
|
(documents, guardrails, sign-in, high availability, pictures and 3D), and
|
||||||
|
its install steps are the ones of today, leading to the guide.
|
||||||
|
- **The company's MCP tools under the guardrails, a rate and a breaker.**
|
||||||
|
- **The answer:** the caller's policies read a tool's answer as the prompt it becomes. An instruction planted in a
|
||||||
|
ticket is withheld as a pasted one would be, and personal data is redacted.
|
||||||
|
- **The arguments:** they are read before they leave, so a secret pasted into one does not reach the company system.
|
||||||
|
- **Rate:** `ctl mcp add --rate N` allows each caller N calls a minute to a server.
|
||||||
|
- **Breaker:** five failures in a row take a server out for a minute, its tools off the list.
|
||||||
|
|
||||||
|
- **How fast the judge is, measured.** `llmgw ctl guard judge check` (and `POST /api/v1/guard/judges/check`) works
|
||||||
|
out whether the judge keeps up with requests. It times three judgments of a 280-token prompt, then a burst at once,
|
||||||
|
and sets both against the policies' wait. It says ok, tight or slow, about how many requests a second can be judged,
|
||||||
|
and what to do: more cores, more judges, a GPU judge, a longer wait, or `fail: open`. `server init` runs it once the
|
||||||
|
judge is up. If the machine is too slow it says so before the next steps, not after the first refused request.
|
||||||
|
|
||||||
|
- **The company's own MCP servers through llmgw's `/mcp`.** `llmgw ctl mcp add jira --url … --secret-file
|
||||||
|
Authorization=FILE` registers one; `/mcp` then lists its tools as `jira.search` beside llmgw's own. Only keys granted
|
||||||
|
them see them: `llmgw keys limits --tools 'jira.*'`, patterns at any level of the tenancy tree. None granted anywhere
|
||||||
|
is none. The server's credentials stay on the control planes, kept in the store sealed or read by reference. The
|
||||||
|
server is told the calling key, person and project. Each call is bounded in time and size, and its progress passes
|
||||||
|
on to a streaming client. Every call is in the audit log with its arguments' size and digest, never their text. The
|
||||||
|
admin API has `/api/v1/mcp/servers`.
|
||||||
|
- **The cluster as a read-only MCP server.** Registered as `llmgw ctl mcp add llmgw --url llmgw:admin` and granted
|
||||||
|
as `llmgw.*`, its tools say what `llmgw ctl` does:
|
||||||
|
- `cluster_status`, `list_nodes`, `list_models`, `list_replicas`, `explain_placement`, `node_logs`;
|
||||||
|
- nothing that changes the cluster.
|
||||||
|
|
||||||
|
It is answered in the control plane's own process, so there is no token to keep and every control plane serves it.
|
||||||
|
The admin API serves the same at `POST /api/v1/mcp`. `ctl mcp add --bearer-file` keeps a token, sent as
|
||||||
|
`Authorization: Bearer`.
|
||||||
|
|
||||||
|
- **Large models across cards and nodes.** For a model of experts on more than one card, the plan now adds vLLM's
|
||||||
|
`--enable-expert-parallel`. It reads the expert count from `config.json` under any of its names, and sizes a model
|
||||||
|
that also reads pictures by its `text_config`. Several nodes are offered in any number, not only powers of two. Each
|
||||||
|
way now says what it needs of a card, part by part (weights, context, engine), and of all its cards together. This
|
||||||
|
shows in `ctl model plan`, in the console's Serve a model, and in a serve job's first step. The serve form checks that
|
||||||
|
a longer context still fits. `ctl model serve --env KEY=VALUE` puts engine variables such as `NCCL_DEBUG=INFO` on
|
||||||
|
every node. docs/large-models.md has the rules, the commands, and a checklist for bringing a model up on a fabric.
|
||||||
|
|
||||||
|
- **Machines of the judges' own in a three-machine install.** The setup screen asks for up to two judge machines after
|
||||||
|
the store's two; `server init` takes `--judge-node user@host` for each. Each is added as a node labelled
|
||||||
|
`llmgw.role: judge`, and the judge's profile selects it, so the judges run there alone. Under load on a 4-core
|
||||||
|
control plane that was also a node, 38 of 60 requests were refused for want of a judgment. With the judges on a node
|
||||||
|
of their own, 2 were. A node with an `llmgw.role` label takes only profiles whose node selector asks for that role,
|
||||||
|
so no other model is placed on the judges' machine.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A machine too small for the models it would serve is said so first.**
|
||||||
|
The control plane that is also a node, serving the starter model and two
|
||||||
|
judges, needs about 10.5 GB. On 8 GB the install finished every step and
|
||||||
|
then had no room for the starter. Now the setup's checks fail its memory
|
||||||
|
with what it would serve, and `server init` refuses before it installs
|
||||||
|
anything, naming `--no-model`, `--no-guard-model` and `--no-node`.
|
||||||
|
|
||||||
|
- **Turkish pages are Turkish to the end.**
|
||||||
|
- **Errors:** those from the store, catalog and mirror that a page shows ("not found", "not empty", "already in the
|
||||||
|
catalog", the settings changed meanwhile, and others) are now in the page's language.
|
||||||
|
- **Percent:** a percentage is written the language's way: `%93` in Turkish, `93%` in English.
|
||||||
|
- **Footer:** the time at the foot of a page is in the language's date form.
|
||||||
|
- **Stale forms:** they are refused with a sentence in that language rather than "bad request".
|
||||||
|
|
||||||
|
- **`llmgw keys limits` no longer clears the limits it was not given.** Setting one limit at a scope wrote the whole
|
||||||
|
row, so `--tools 'jira.*'` alone took away that scope's rate, budget and model list. Only what is given changes now.
|
||||||
|
`--replace` writes every limit, as the console's limits form does.
|
||||||
|
|
||||||
|
- **The plan refused tensor parallelism past a model's KV heads.** Qwen3-235B, with four KV heads, had no way to run
|
||||||
|
on eight cards, though vLLM gives each card a copy of a head. A width that is a multiple of the KV heads is now
|
||||||
|
offered, as is one that divides them.
|
||||||
|
- **A big model on a slow mirror was failed while it downloaded.** The startup timeout counted the download, so
|
||||||
|
hundreds of gigabytes at a few hundred MB/s ran past twenty minutes and the replica was started again from nothing.
|
||||||
|
The timeout now runs from when a node's download last moved.
|
||||||
|
|
||||||
|
- **The judge init installs is no longer replaced by the leader's own.** Just after the control plane started, the
|
||||||
|
leader saw a policy naming a judge that init was still fetching, and served one itself. Its entry replaced init's, so
|
||||||
|
a second judge ran with the leader's sizing, and with judge machines it ran off them. The leader now waits ten minutes
|
||||||
|
after it starts before it serves a judge itself. If someone adds one while it fetches, theirs stays.
|
||||||
|
- **A judge the leader serves by itself is sized as init's is.** When a guard policy names a judge the catalog lacks,
|
||||||
|
the leader serves it. That judge reserved 3 GB and no cores, and ran llama.cpp's prompt cache, which grew one to
|
||||||
|
3.2 GB. Without cores of its own, a judgment on a busy node missed its 5 s budget, and `fail: closed` refused the
|
||||||
|
request. It now gets what `server init` gives its judges: 2 GB, two cores each (fewer on a small node), and no
|
||||||
|
prompt cache. Every judge served through `modelserve` runs with `--cache-ram 0`.
|
||||||
|
|
||||||
|
## v0.14.9 — 2026-10-19
|
||||||
|
|
||||||
|
Sign-in set up from the console, with Active Directory tried before it is
|
||||||
|
kept. A node's console now shows what the machine is doing, and a node's web
|
||||||
|
page shows what it runs. Also fixes found trying Active Directory against a
|
||||||
|
Samba domain controller on the test cluster. Sign-in through it, nested
|
||||||
|
groups and a disabled account suspended by the sync all worked there.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- No migration.
|
||||||
|
- `auth.ldap` and `auth.oidc` are in the cluster's settings, as before. A
|
||||||
|
block written into `server.yaml` on a cluster with a store was never
|
||||||
|
read; the log said so. Put it in with `llmgw ctl config edit`, or set it up
|
||||||
|
again from the console's Sign-in screen.
|
||||||
|
- A Samba DC's own autogenerated certificate is refused (a negative serial
|
||||||
|
and no SAN). Give it one from a CA.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Sign-in set up from the console.** Under People → Sign-in, `sudo llmgw` shows how people get in: single sign-on,
|
||||||
|
Active Directory and SCIM, each on or off. It also shows the directory's last sync. **E** sets up Entra ID from the
|
||||||
|
tenant and client IDs. **D** sets up Active Directory, and the directory is tried before it is kept: each server
|
||||||
|
reached and bound, how many people may sign in, how many are in each group. Nothing is kept if a step fails. The
|
||||||
|
password, client secret and SCIM token are kept in the store as `store:auth/…`, sealed by the master key. The
|
||||||
|
domain's CA goes into the settings (`auth.ldap.ca`), so no file has to be put on each control plane. The admin API
|
||||||
|
has the same: `GET /api/v1/auth`, `POST /api/v1/auth/ldap/test` and `PUT /api/v1/auth/secrets/{name}`.
|
||||||
|
- **A node's page says what it runs.** It now shows the agent's version, marked when the control plane runs another,
|
||||||
|
and the engines with their versions. Each GPU row now names the models loaded on that card, and each engine row its
|
||||||
|
GPUs.
|
||||||
|
- **`sudo llmgw` on a node opens on what the machine is doing.** A new Overview screen shows the agent's state and
|
||||||
|
version, each control plane it dials and whether it answers, every GPU with the models on it, the engines running
|
||||||
|
here, the free space for weights and engines, and the agent's warnings of the last hour. It is read on the node
|
||||||
|
itself, every three seconds, so it needs no admin token and works while the control plane is out of reach.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A broken `auth.ldap` no longer stops every control plane.** It is the cluster's setting. A CA file that one
|
||||||
|
machine could not use made `llmgw server` exit on every control plane, and no `ctl config` could then put it right.
|
||||||
|
Directory sign-in is now turned off on that control plane, with an error in its log. Local accounts still sign in.
|
||||||
|
- **A second `llmgw server` on a machine stops before it competes to lead.** `llmgw server check` (no such command)
|
||||||
|
ran a whole server. Its ports were taken, yet it went on to hold the leader's lease for three hours while the real
|
||||||
|
control planes stood by. `server` now refuses words it does not know and takes its ports before anything else.
|
||||||
|
- **The single sign-on and Active Directory guides said `server.yaml`.** On a cluster with a store, `auth` is in the
|
||||||
|
cluster's settings and the file's block is not read. They now say `llmgw ctl config edit`, and what certificate a
|
||||||
|
domain controller needs.
|
||||||
|
|
||||||
|
## v0.14.8 — 2026-10-18
|
||||||
|
|
||||||
|
Sign-in with an on-premises Active Directory, and Entra ID made safe for
|
||||||
|
people in many groups. Also several keys for one outside provider, and a
|
||||||
|
measure of what the gateway adds to a request.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- Migration 0048 adds the `user_directory` table, applied when the control
|
||||||
|
plane starts.
|
||||||
|
- Nothing changes until `auth.ldap` is set; docs/install.md, "Active
|
||||||
|
Directory", has an example. It has been tested against a fake directory,
|
||||||
|
not yet against a real domain controller.
|
||||||
|
- A judge installed by an earlier `server init` is refitted by the leader
|
||||||
|
by itself (below); the steps given for v0.14.7 are no longer needed.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Sign-in with Active Directory** (`auth.ldap`). People sign in on the gateway's form with their AD username or mail
|
||||||
|
and password, checked against the domain controllers over LDAPS or StartTLS. Groups, nested ones included, give
|
||||||
|
staff roles and places in the tree, as with OIDC. Accounts are tied to the `objectGUID` and made at first sign-in
|
||||||
|
when `create_accounts` is on. Every 15 minutes the leader reads the directory. People disabled or deleted there are
|
||||||
|
suspended and reinstated when they come back; roles and memberships follow their groups. A sync that would suspend
|
||||||
|
more than half the accounts is refused. See docs/install.md, "Active Directory".
|
||||||
|
|
||||||
|
- **Several keys for one outside provider.** An external model takes `api_key_files`, each with an optional weight, in
|
||||||
|
place of `api_key_file`. Each key is an endpoint of its own (`external:MODEL#1`, `#2`), so a key answering 429 is
|
||||||
|
passed over while its breaker is open and the request goes to another. A key answering 401 is passed over too, and
|
||||||
|
`external_key_refused` is sent, naming the endpoint, never the key. Usage records which key answered by that name.
|
||||||
|
|
||||||
|
- **`llmgw ctl bench overhead`**: what the gateway adds to a request, measured against an engine that answers at
|
||||||
|
once. Gateway and engine run in one process on loopback, so no cluster is needed. The load is sent straight to the
|
||||||
|
engine and through the gateway: bare, with authentication and accounting, and with the guardrails' pattern checks.
|
||||||
|
It reports p50 and p99 latency, what the gateway added, and a rough CPU per thousand requests. On an Apple M5 the
|
||||||
|
gateway added 0.05–0.42 ms at p50 and at most 0.49 ms at p99 (README).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Entra ID's groups past 200 no longer take a role away.** For someone in more than 200 groups Entra sends none in
|
||||||
|
the token, only a pointer to Graph. That was read as no groups, so an administrator lost their role at the next
|
||||||
|
sign-in. Their role and memberships are now left as they were, and an error is logged saying what to change in the app
|
||||||
|
registration. docs/install.md has an Entra ID section.
|
||||||
|
- **An account made by SCIM or OIDC could not be reinstated.** Setting it active was refused as "never approved".
|
||||||
|
- **An upgrade refits the judge an earlier `server init` installed.** Upgrading to v0.14.7 left such a judge as it was:
|
||||||
|
no cores or one, 2 or 4 GB, the prompt cache on. On fewer than eight cores every request was refused until it was
|
||||||
|
fixed by hand. Now the leader finds a judge whose entry is exactly what an earlier init wrote and gives it 2 GB, no
|
||||||
|
prompt cache and cores by today's rule (up to four for one judge, two each for more). It then restarts the judge's
|
||||||
|
replicas onto that. The number of replicas is left as it is. A judge whose entry was changed by hand is left alone,
|
||||||
|
and the log says so once.
|
||||||
|
- **A restart is no longer refused for room another restart is giving back.** With every core of a node reserved, two
|
||||||
|
models restarted together, one to need more and one less (as after `ctl model resources` on a judge and a starter),
|
||||||
|
had the first refused: "fits on no node even with this replica stopped", though the second was about to give back
|
||||||
|
its cores. A restart now waits for other restarts under way, up to the drain grace, before it is refused. A drained
|
||||||
|
replica also counts a replica being restarted that still answers as serving, so replicas restarted one after
|
||||||
|
another no longer wait out the five-minute grace each.
|
||||||
|
|
||||||
|
## v0.14.7 — 2026-10-16
|
||||||
|
|
||||||
|
The judge installed by `server init` sized for what it does. On a machine
|
||||||
|
of fewer than eight cores v0.14.6 left every judgment past its budget, so
|
||||||
|
`fail: closed` refused every request. A single-machine install of v0.14.7
|
||||||
|
on a 4-core VM passed 20 of 20, and on 8 cores 20 of 20 under load.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- No migration.
|
||||||
|
- **Upgrading does not change a judge already in the catalog.** v0.14.6
|
||||||
|
and earlier installed two judges, reserving no cores or one, and 2 or
|
||||||
|
4 GB. To give them what they need:
|
||||||
|
`llmgw ctl model resources qwen3guard-0.6b --memory 2 --cores 2`, then
|
||||||
|
`llmgw ctl restart --model qwen3guard-0.6b`. The two then need four cores
|
||||||
|
on a single machine; below that, a longer `latency_budget` or
|
||||||
|
`fail: open`.
|
||||||
|
- On a node whose cores are all reserved, `ctl restart --model` cannot
|
||||||
|
start the new replica beside the old one and waits. Stop the old engine
|
||||||
|
process, and the replica comes back with the new figures.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Both judges fit an 8 GB machine again.** v0.14.6 had `server init` reserve 4 GB a judge replica. That figure came
|
||||||
|
from one replica at 3.2 GB after a day of traffic. The growth was llama.cpp's prompt cache, up to 8 GB by default,
|
||||||
|
which a judge never reuses: each text it reads is different. On a 4-core, 8 GB machine the second judge then fitted
|
||||||
|
nowhere ("has 7 GB of memory with 1 free, needs 4"). The judge now runs with `--cache-ram 0`, uses about 1.6 GB and
|
||||||
|
stays there, and reserves 2.
|
||||||
|
- **One machine runs one judge, on up to four cores.** A second replica on the same machine shares its cores and is
|
||||||
|
lost with it, so it bought nothing but half the speed of each. `server init` on a single machine now serves one judge
|
||||||
|
on up to four cores (2.3 s a judgment where two on two cores took 4.2). Where there will be other nodes, it serves two
|
||||||
|
on two cores each, as in HA setups. `--judge-replicas` and the setup's Copies still choose.
|
||||||
|
- **A judge gets the two cores it needs.** v0.14.6 gave each judge replica one core on machines under eight. A judgment
|
||||||
|
of about 280 tokens takes 8.0 s on one core, 4.2 s on two and 2.3 s on four, against the 5 s budget, so on a 4-core
|
||||||
|
machine every judgment timed out and every request was refused. Where there are two judges, each now reserves two
|
||||||
|
cores (one only below four CPUs), and the one judge of a single machine up to four; the starter has what is left, or
|
||||||
|
shares when nothing is. A judge on two cores stays close to its budget: give it more cores or a GPU, or raise
|
||||||
|
`latency_budget`, where requests must not wait.
|
||||||
|
|
||||||
|
## v0.14.6 — 2026-10-16
|
||||||
|
|
||||||
|
Setup that says what each part of an install is and lets every one be
|
||||||
|
chosen and set; three machines that each run the gateway, so any one can
|
||||||
|
be lost; a cluster that loses its store's primary without losing a
|
||||||
|
request; one ComfyUI for an environment's models; pictures, 3D and
|
||||||
|
workflows as MCP tools; alerts asked for at setup; extended thinking.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- No migration.
|
||||||
|
- **Control planes**: with `control.lease_ttl` unset the lease is now 10 s
|
||||||
|
(was 15). Upgrade the standbys first and the leader last; a mix of
|
||||||
|
versions meanwhile is fine.
|
||||||
|
- **The store** (PostgreSQL): each member is dialled for 3 s before the
|
||||||
|
next is tried. A store so far away that connecting takes longer needs
|
||||||
|
`connect_timeout=10` in its address.
|
||||||
|
- **Nodes** notice a control plane that died without closing its
|
||||||
|
connection in 8 s (was 15) once they run this release.
|
||||||
|
- **Judges installed by an earlier `server init`** keep reserving no cores
|
||||||
|
and 2 GB. To give them their own:
|
||||||
|
`llmgw ctl model resources qwen3guard-0.6b --memory 4 --cores 2`, then
|
||||||
|
`llmgw ctl restart --model qwen3guard-0.6b`.
|
||||||
|
- **Patroni** replaces a lost primary within 20 s on a new cluster. On an
|
||||||
|
existing one, run `server init --patroni ...` again from the machine with
|
||||||
|
the authority; it changes only that.
|
||||||
|
- **A floating address** keeps VRRP router id 51 until `server add --vip`
|
||||||
|
runs again, which moves it to one of its own; run it for every standby.
|
||||||
|
- A request whose judge is not there yet waits up to the policy's
|
||||||
|
`latency_budget` for one before `fail: closed` refuses it.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Setup lets every part be chosen, each with its settings, and says what each is.** `sudo llmgw` on a new machine lists
|
||||||
|
the parts of an install: the control plane, the store, the model mirror, this machine as a node, the starter model,
|
||||||
|
pictures and 3D, the guardrails' judge, the firewall, notifications, limits and keys, on-demand turns, an offline
|
||||||
|
bundle. Beside each is what it will be or what is missing without it. Space turns a part on or off, and Enter opens
|
||||||
|
its settings in a dialog that explains it at more length. The settings are: which starter model (six Qwen sizes, or
|
||||||
|
any GGUF on Hugging Face) and its context; the mirror's directory or another machine's mirror; the judge's
|
||||||
|
categories, copies, wait and what happens when it does not answer; the portal's language; the sign-up form; the
|
||||||
|
default limits; the longest a key lives; the on-demand waits. A plan page then lists what is installed and what is
|
||||||
|
left out, with the same install as a command. Every setting is a `server init` flag too (`--starter`,
|
||||||
|
`--judge-fail`, `--media`, `--max-key-ttl`, ...).
|
||||||
|
- **Three machines are high availability for the gateway, not only the store.** With three machines, setup makes the two
|
||||||
|
database machines control planes too, standing by while the first leads, with an optional floating address
|
||||||
|
(keepalived) that follows whichever leads. Any one machine can now be lost. By command: `server init --patroni ...
|
||||||
|
--standbys --vip 10.0.0.160/24`. The mirror's management API answers the three control planes, so a standby that
|
||||||
|
comes to lead can still fetch models.
|
||||||
|
|
||||||
|
- **Pictures uploaded to work from**: `/v1/files` takes `purpose=vision` or
|
||||||
|
`user_data` besides `batch`, kept as long as job results, so a picture is
|
||||||
|
sent once and named by its `file-…` id in the MCP tools and a workflow's
|
||||||
|
inputs.
|
||||||
|
- **One ComfyUI for an environment's models**: ComfyUI models in one
|
||||||
|
`comfyui.environment` (two or more) share one replica — the catalog
|
||||||
|
makes it a hidden entry, `comfyui-env-<name>`, on the first member's
|
||||||
|
profiles with the most memory any member needs, carrying every member's
|
||||||
|
weights and custom nodes. Asking for any member starts it; it answers for
|
||||||
|
each with that member's workflow and checkpoint, and the members get no
|
||||||
|
replicas of their own. A picture model and a 3D model on one card stop
|
||||||
|
handing it to each other with a cold start each way.
|
||||||
|
- **Pictures, 3D models, vision and workflows as MCP tools** at `/mcp`,
|
||||||
|
beside the memory notes, each offered only where the gateway has a model
|
||||||
|
for it: `generate_image` (with `concept`, `side` and `texture` styles),
|
||||||
|
`generate_mesh` (from a picture or a prompt), `review_images` (a vision
|
||||||
|
model, to a JSON schema), `run_workflow`, `job_status` and
|
||||||
|
`gateway_status`. Each makes its request under the caller's key, so
|
||||||
|
allowlists, limits, licences and budgets apply and it is charged like
|
||||||
|
any request; what it makes is kept as the caller's file and answered by
|
||||||
|
id and download URL. A client that takes a stream gets
|
||||||
|
`notifications/progress` while a mesh is made and waits up to ten
|
||||||
|
minutes; one that does not, 50 seconds, then the job id.
|
||||||
|
- **Setup asks where alerts go**: `llmgw server init --webhook URL`
|
||||||
|
(repeatable) and a **Notification webhooks** field on both setup screens.
|
||||||
|
Slack's, Mattermost's and Rocket.Chat's addresses are sent Slack's text,
|
||||||
|
any other the event as JSON (`slack:` or `json:` in front says which).
|
||||||
|
They are added to the cluster's `notify.webhooks` beside those already
|
||||||
|
there, and each is sent a test once the control plane runs on them.
|
||||||
|
|
||||||
|
- **Extended thinking on `/v1/messages`**: `thinking: {type: "enabled"}`
|
||||||
|
tells a model that thinks by its chat template to (`enable_thinking`),
|
||||||
|
and its reasoning comes back as `thinking` blocks before the text,
|
||||||
|
streamed as `thinking_delta` events; `disabled` tells it not to. Not
|
||||||
|
asked for, the reasoning is left out as before.
|
||||||
|
- **How long on-demand models hold a card** is a setting, in force on the
|
||||||
|
leader within seconds: `on_demand.evict_after_idle` (2m unless set) and
|
||||||
|
`on_demand.min_lifetime` (5m). On one card shared by a picture model and
|
||||||
|
a 3D model, 1m each brings the wait between a picture and its mesh to
|
||||||
|
about a minute.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **The judges and the starter model divide a machine's CPUs instead of each taking all of them.** `server init` gave the
|
||||||
|
judge and the starter no cores of their own, so every llama.cpp engine ran on every core: two judges and the starter
|
||||||
|
on a 4-core control plane competed, and under load 38 of 60 requests were refused for want of a judgment. Moved to a
|
||||||
|
node of their own with 2 cores each, the same test refused 2. When this machine is the node, init now gives each judge
|
||||||
|
replica 1 core (2 from 8 cores) and the starter what is left (at most 8); judges bound for other nodes get 2 each. A
|
||||||
|
judge also reserves 4 GB, not 2: it uses about 3.2.
|
||||||
|
|
||||||
|
- **A lost store machine stops costing requests most of a minute.** The store's connections try each member for 3 seconds,
|
||||||
|
not until the kernel gives up, and a connection to a machine that is gone is closed within about 10 seconds (TCP
|
||||||
|
keepalive, and `TCP_USER_TIMEOUT` on Linux). Patroni replaces a lost primary within 20 seconds (`ttl` 20, `loop_wait`
|
||||||
|
5, `retry_timeout` 5). Running `server init --patroni` again applies this to an existing cluster. Measured on three
|
||||||
|
VMs with the primary's power cut: about 45 seconds of hanging requests before.
|
||||||
|
- **Leadership stays put while the store's primary is replaced.** Replacing a lost primary takes longer than the
|
||||||
|
leadership lease lives, so the lease had lapsed for everyone by the time the store answered again. Whoever asked first
|
||||||
|
took it, as often a standby as the leader. A standby that saw the store out of reach now leaves the lease alone for one
|
||||||
|
round after it returns, so the leader renews it, and a leader that is truly gone is replaced a round later. With that,
|
||||||
|
the default lease is 10 seconds (was 15). A node notices a control plane that died without closing its connection in
|
||||||
|
8 seconds (was 15), so it is on its way to the new leader as that one takes over.
|
||||||
|
- **A judge replica that hangs no longer costs the request.** All the tries of a judge request shared the policy's
|
||||||
|
budget, so a replica that hung used it all and the try on another replica had none left: under `fail: closed` the
|
||||||
|
request was refused while a judge stood idle. When the first replica has not answered by half the budget, the same
|
||||||
|
question now goes to a replica on another machine too, and the first answer is taken. The first replica is not cut
|
||||||
|
short, which on a slow machine would refuse answers it was about to give. Replicas on one machine whose CPUs are taken
|
||||||
|
are slow together, so asking both only doubles the work there: measured that way, as many requests failed as before.
|
||||||
|
On such a machine the remedy is room, not a second question: judges on more than one node, more cores, or the judge
|
||||||
|
on a GPU.
|
||||||
|
- **A new leader waits for the judge instead of refusing at once.** The moment a control plane takes the lead it knows no
|
||||||
|
judge yet. When every judge fails at once, they are asked again every 250 ms while the policy's `latency_budget` lasts,
|
||||||
|
so `fail: closed` no longer answers every request in those seconds with 503 (about 17 seconds of them in the test). A
|
||||||
|
judge that is slow is not asked again, and an alarm is raised once per request, not once per try.
|
||||||
|
|
||||||
|
- Node discovery for Prometheus listed only the control plane asked and the
|
||||||
|
store, so a machine that is both was a control plane to one and a store
|
||||||
|
member to the other — scraped twice — and a standby asked alone lost every
|
||||||
|
node. Every control plane lists all of them alike; deploy/monitoring's
|
||||||
|
prometheus.yml says to scrape and ask each.
|
||||||
|
- A model whose weights and context pool did not fit its card was added
|
||||||
|
and then failed to make its context on every start, taking the card from
|
||||||
|
others each time (Apertus 70B with a 65536-token pool, 8B with 131072, on
|
||||||
|
24 GB). `ctl model serve` shrinks the pool to what the card holds, saying
|
||||||
|
so, and refuses weights that do not fit at all; the portal's serve form
|
||||||
|
refuses such a pool and says the largest that fits.
|
||||||
|
- Removing weights from the mirror that a catalog model still loads is
|
||||||
|
refused, naming the model: removed from the mirror alone, the model stayed
|
||||||
|
in the catalog and its replicas went on loading the node's copy.
|
||||||
|
- Jobs ran one at a time, so a 3D job waiting for the card held up another
|
||||||
|
model's job that could have run. They run side by side, one per model at
|
||||||
|
a time and eight in all, on the leader as before.
|
||||||
|
- `llmgw ctl model serve` served an embedding model as a chat model: no
|
||||||
|
`--embedding`, four slots, a 512-token batch, `embeddings` unset and a
|
||||||
|
"say hello" smoke test it could not pass. A model whose GGUF says it
|
||||||
|
pools (or `--embeddings`) is served for embedding: 32 slots of 1024
|
||||||
|
tokens unless sized otherwise, a batch that holds a whole passage, and
|
||||||
|
one vector asked for as its smoke test.
|
||||||
|
- `llmgw ctl upgrade` checks every node's version once the waves are done
|
||||||
|
and fails naming any not on the release, and says when the control plane
|
||||||
|
has no nodes registered; `llmgw upgrade` on a control plane says how many
|
||||||
|
nodes it left behind and the command that upgrades them. A site's nodes
|
||||||
|
stayed a release behind with nothing to say so.
|
||||||
|
- The cluster and node pages swapped their live part every few seconds,
|
||||||
|
forms and all, so filling one in raced the clock: what was typed, an
|
||||||
|
open choice and the focus were lost. The refresh now waits while a field
|
||||||
|
in it has the focus or a form in it has been typed into and not sent;
|
||||||
|
the mirror page's reload during a download waits the same way.
|
||||||
|
|
||||||
|
## v0.14.5 — 2026-10-14
|
||||||
|
|
||||||
|
Picture and 3D models that add themselves where a card fits them, with a
|
||||||
|
card on the portal and a screen in the console to see and add them; a
|
||||||
|
mesh's colour brought to the picture it was made from; and the whole
|
||||||
|
licence written into every mesh.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- The store gains a table (migration 0047: preset_state); nothing to do.
|
||||||
|
- **`presets.auto` is on**: once upgraded, the leading control plane adds
|
||||||
|
flux-schnell and triposr, on demand, where a node's card fits one, the
|
||||||
|
cluster installs ComfyUI (`engines.comfyui`) and the catalog makes
|
||||||
|
nothing of that kind. Nothing is fetched until somebody asks for one.
|
||||||
|
Turn it off with `llmgw ctl model presets auto off`.
|
||||||
|
- **Nodes** need this release for `albedo_match` (it is in the save node
|
||||||
|
the gate puts in each replica); a ComfyUI replica takes it when it
|
||||||
|
starts again.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **A mesh's base colour brought to its picture's**: `LLMGWSaveMesh` takes
|
||||||
|
`albedo_match` with the picture and its mask, and scales the texture in
|
||||||
|
linear light to the object's brightness in the picture. Stable Fast 3D
|
||||||
|
takes the light out of what it sees and read a dark basalt (0.17) as a
|
||||||
|
light stone dimly lit (0.67); matched, it is 0.17. On sf3d it is the
|
||||||
|
`albedo_match` input (off unless asked).
|
||||||
|
- **Picture and 3D models by themselves**: once a node has a card that fits
|
||||||
|
flux-schnell or triposr and the catalog makes nothing of that kind, the
|
||||||
|
leading control plane adds the preset on demand — so a new cluster's
|
||||||
|
portal has its Images and 3D pages without a definition written — where
|
||||||
|
the cluster installs ComfyUI. `presets.auto` turns it off; a preset
|
||||||
|
somebody removes is not added back. `GET /api/v1/presets` says how each
|
||||||
|
stands and why one is not added; `POST /api/v1/presets/{name}` adds one
|
||||||
|
(and sets `engines.comfyui` if the cluster has none); `PUT
|
||||||
|
/api/v1/presets/auto`. Each automatic addition is in the audit log.
|
||||||
|
- **Presets on the portal and in the console**: the cluster page's
|
||||||
|
**Pictures and 3D models** card lists each preset — what it makes, the
|
||||||
|
classes it fits here, whether and by whom it was added, why the leader
|
||||||
|
has not — with an **Add** button that says first when it would install
|
||||||
|
ComfyUI and restart the control planes, and the switch for `presets.auto`.
|
||||||
|
The console's models screen shows the same; **A** adds one, **P** flips
|
||||||
|
the switch. `ctl model add PRESET` and `ctl model presets` go through the
|
||||||
|
API (and `ctl model presets auto on|off`), falling back to the entry as
|
||||||
|
v0.14.4 sent it on an older control plane.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A mesh carried only its licence's name, URL and notice; it carries the
|
||||||
|
whole licence as `/v1/models` gives it — id, commercial use, excluded
|
||||||
|
regions, revenue cap, attribution — for a pipeline that sorts assets by
|
||||||
|
licence.
|
||||||
|
- A workflow's `{{name|false}}` default filled a BOOLEAN input with the
|
||||||
|
word rather than a boolean.
|
||||||
|
|
||||||
|
|
||||||
|
## v0.14.4 — 2026-10-13
|
||||||
|
|
||||||
|
Any ComfyUI workflow as a job, with typed inputs; picture and 3D models
|
||||||
|
added by name; profiles that fit whatever card a site has, and ComfyUI on
|
||||||
|
a V100; licences a machine can match and a person can leave out; a job
|
||||||
|
waiting for the one card says what holds it. Many fixes to on-demand
|
||||||
|
models taking turns on one card.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- The store gains a table (migration 0046: user_excluded_licences);
|
||||||
|
nothing to do.
|
||||||
|
- **Nodes** need this release for ComfyUI on Volta or Pascal cards (the
|
||||||
|
cu126 build), for a driver from NVIDIA's open kernel module to be read,
|
||||||
|
and for a node that loads its weights by repository name (sf3d); a
|
||||||
|
ComfyUI already installed is kept.
|
||||||
|
- **Control planes before nodes**: an entry using the built-in `triposr`
|
||||||
|
workflow, or a `gpu:` need instead of a class, is refused by an older
|
||||||
|
control plane.
|
||||||
|
- Models added with `llmgw ctl model add <preset>` take the preset's name;
|
||||||
|
remove an entry of that name first.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **The client API described**: OpenAPI 3.1 at `/v1/openapi.json`, no key
|
||||||
|
needed, generated from a route table a test holds to the router's own;
|
||||||
|
and `/docs/api`, Swagger UI (served from the gateway) over it and, for
|
||||||
|
staff, over the admin API's document.
|
||||||
|
- **A job waiting for the card says so**: `progress` is
|
||||||
|
`{"state":"waiting_gpu","held_by":…,"eta_seconds":…}` while another
|
||||||
|
on-demand model holds it; a direct request's 503 carries
|
||||||
|
`X-Llmgw-Waiting-For` and `X-Llmgw-Eta-Seconds`.
|
||||||
|
- **Any ComfyUI workflow, as a job**: `POST /v1/workflows/runs` with
|
||||||
|
`{model, inputs}`. A model's catalog entry declares its inputs
|
||||||
|
(`comfyui.inputs`: text, int, float, bool, choice, image, mask, audio,
|
||||||
|
video, file, with bounds and defaults) and its output (`comfyui.output`:
|
||||||
|
pictures, meshes, video, audio or files); a request is checked against
|
||||||
|
them before a job is written, file inputs are base64, a data URL or a
|
||||||
|
`/v1/files` id, and `/v1/models` lists each model's `workflow` so a
|
||||||
|
client can build its form. A PNG result carries the licence in an
|
||||||
|
`iTXt` chunk, a GLB in `asset.extras` as before.
|
||||||
|
- **Environments**: `comfyui.environment` names ComfyUI models that agree
|
||||||
|
on their custom nodes and may share one replica; the catalog refuses
|
||||||
|
one that cannot.
|
||||||
|
- **Profiles that fit the hardware**: a profile may say `gpu:
|
||||||
|
{min_compute_capability, min_memory_gb}` instead of `gpu_class`, and
|
||||||
|
stands for every class in the cluster that meets it, as
|
||||||
|
`<profile>@<class>`; a ComfyUI profile may carry its own `comfyui`
|
||||||
|
workflow, checkpoint and nodes, and any profile its own `source`. One
|
||||||
|
catalog entry then runs fp8 where the card has it and fp16 on Turing.
|
||||||
|
- **Custom nodes that compile**: `nodes[].build` names directories of a
|
||||||
|
node that are C++/CUDA packages; the gate builds each as a wheel for the
|
||||||
|
card the replica runs on, keeps it on the node, and installs it beside
|
||||||
|
the replica. `comfyui.huggingface_repo` shows a model's weights to a node
|
||||||
|
that loads them by repository name.
|
||||||
|
- **A licence a machine can match**: `license.id`, `commercial`,
|
||||||
|
`excluded_regions`, `revenue_cap_usd`, `attribution`.
|
||||||
|
- **On-demand models in `/v1/models`**, with `loaded` saying whether one is
|
||||||
|
up now; a picture is cut out and put on grey before TripoSR sees it
|
||||||
|
(`LLMGWPrepareForMesh`, rembg when it has no alpha).
|
||||||
|
- **Licences a person leaves out**: on the profile page, by licence, or by
|
||||||
|
rule — every licence forbidding commercial use, every licence granting
|
||||||
|
nothing in a region — so a model under one is neither listed to nor
|
||||||
|
served for that person's keys and sessions (403 `licence_excluded`).
|
||||||
|
- **Presets: a picture or 3D model added by name.** `llmgw ctl model add
|
||||||
|
flux-schnell` or `triposr` adds an entry that comes with llmgw — its
|
||||||
|
workflow (TripoSR's now built in), custom nodes, packages and licence,
|
||||||
|
and a profile by card need rather than class — and has the nodes install
|
||||||
|
ComfyUI when the cluster's settings name none. `llmgw ctl model presets`
|
||||||
|
lists them; the console's **A** on the models screen offers them beside
|
||||||
|
a definition file.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A model waiting for another on-demand model to give up the card failed at
|
||||||
|
once as "cannot be placed"; it now waits, and a job with it.
|
||||||
|
- A model evicted for being idle was placed again on the next pass, ahead of
|
||||||
|
the one it made room for.
|
||||||
|
- A job or batch leased by a standby control plane, whose router has no
|
||||||
|
endpoints, waited on "loading" for good; they are served only by the
|
||||||
|
leader. A job told its model fits nowhere asks again for five minutes
|
||||||
|
before failing, which an agent re-registering makes true for seconds.
|
||||||
|
- An on-demand replica adopted after a restart was never idle enough to
|
||||||
|
evict, so a model waiting for its card waited out its whole idle timeout;
|
||||||
|
it counts as used at adoption.
|
||||||
|
- A picture in a prompt was counted as its base64 by the context check, so
|
||||||
|
one 1280x720 PNG was "138 thousand tokens" and a vision model refused it;
|
||||||
|
a picture or a sound now counts as about a thousand.
|
||||||
|
- An on-demand model of a higher class was evicted, idle, for a lower-class
|
||||||
|
one two minutes into a lull; it keeps the card through its idle timeout,
|
||||||
|
and a model waiting on it is told so.
|
||||||
|
- A model removed from the catalog stayed wanted, so added again it loaded
|
||||||
|
unasked.
|
||||||
|
- A node that loads its weights by repository name (sf3d) went to Hugging
|
||||||
|
Face for a gated repository and got a 401: the agent did not hand the
|
||||||
|
gate `comfyui.huggingface_repo`, so the weights on the node were never
|
||||||
|
linked into its cache.
|
||||||
|
- **ComfyUI on a V100.** torch's CUDA 12.8 and newer builds have no Volta
|
||||||
|
kernels, but its cu129 build was taken to run from compute capability
|
||||||
|
7.0, so on a V100 both ComfyUI builds were installed, about 10 GB each,
|
||||||
|
and failed with "no kernel image". Cards older than Turing now get
|
||||||
|
ComfyUI on PyTorch's CUDA 12.6 torch (build `cu126`); a Turing or newer
|
||||||
|
node keeps the build it has.
|
||||||
|
- **The open NVIDIA driver read by its version.** With the open kernel
|
||||||
|
module, the default since 560, the agent read the driver as "for" and so
|
||||||
|
as unknown, and tried every engine build whatever driver it needs.
|
||||||
|
|
||||||
|
## v0.14.3 — 2026-10-10
|
||||||
|
|
||||||
|
3D models from a picture, as jobs: made by ComfyUI on the cluster's cards,
|
||||||
|
with their progress, their licence written into the mesh, a price per mesh
|
||||||
|
and a page to make them. Also: a profile's own weights, a band for bulk
|
||||||
|
work, and fixes for control planes behind a balancer and agents that
|
||||||
|
restart.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- The store gains tables and columns (migrations 0043–0045: notify_sent,
|
||||||
|
jobs, usage meshes); nothing to do.
|
||||||
|
- **Nodes** need this release for their engines' output to survive an
|
||||||
|
agent restart and for ComfyUI's custom nodes and progress; engines
|
||||||
|
started by an older agent keep its pipe until they are restarted.
|
||||||
|
- A 3D model needs a model that makes them (see install.md, "A model that
|
||||||
|
makes them"); none is added.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Jobs: 3D models asked after, not waited for.** `POST
|
||||||
|
/v1/3d/generations` answers 202 with a job id; `GET
|
||||||
|
/v1/3d/generations/{id}` says its status, progress and, when done, the
|
||||||
|
file its result is in (`/v1/files/{id}/content`); `POST .../cancel` stops
|
||||||
|
it, in the engine's queue too. The request is replayed by whichever
|
||||||
|
gateway holds the job's lease through the same path a client's request
|
||||||
|
takes — key, allowlist, limits, admission, guardrails, accounting — so
|
||||||
|
it is charged like any request, tagged `job=<id>`, and a model still
|
||||||
|
loading is waited out for as long as the job has (24 h) rather than
|
||||||
|
refused. A job survives the gateway that took it; results are kept for
|
||||||
|
`router.batch.retain_days`. Migration 0044.
|
||||||
|
- **ComfyUI models that make meshes** (`comfyui.output: meshes`). The
|
||||||
|
picture is uploaded to the replica's ComfyUI and the workflow's
|
||||||
|
`{{image}}`; the mesh the workflow saves is the job's file, an OBJ turned
|
||||||
|
into a GLB. TripoSR (MIT) is the tested model; on Ampere and newer cards
|
||||||
|
stronger ones can be added the same way.
|
||||||
|
- **Custom nodes** (`comfyui.nodes`): a repository at a full commit, put in
|
||||||
|
each replica's own `custom_nodes` by the gate, with its packages
|
||||||
|
installed beside the replica, never into ComfyUI's environment.
|
||||||
|
- **A job's progress**: the gate listens to ComfyUI's websocket and the job
|
||||||
|
says queued and how many ahead, then the node running, its step and
|
||||||
|
percent.
|
||||||
|
- **A model's `license`** (name, url, notice), in `/v1/models`, in the job,
|
||||||
|
on the portal and in the GLB's `asset.extras`.
|
||||||
|
- **`per_mesh`** in `auth.pricing`; usage rows, sums and exports count
|
||||||
|
`meshes`.
|
||||||
|
- **`/3d` on the portal**, beside `/images`: a picture chosen, the job
|
||||||
|
followed, the mesh in a viewer with a download and its licence; and
|
||||||
|
**`llmgw ctl 3d list|get|cancel`** for every owner's jobs.
|
||||||
|
- **A profile's own `source`**: the weights it loads instead of the
|
||||||
|
model's, so an embedding model can run on vLLM from its repository on a
|
||||||
|
card and on llama.cpp from a GGUF on CPUs as one model. Placement prefers
|
||||||
|
nodes holding each profile's weights, `ctl model add` checks every
|
||||||
|
source, and `ctl weights` collects none a profile loads.
|
||||||
|
- **A band's own `queue_ttl`** (`router.admission.bands[].queue_ttl`): how
|
||||||
|
long its requests may wait for room, instead of the admission's 30 s. A
|
||||||
|
band for bulk work — a knowledge-base import — waits behind people rather
|
||||||
|
than being turned away.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **An engine's output no longer breaks when its agent restarts.** Engines
|
||||||
|
wrote to a pipe of the agent's, which an agent that restarted and adopted
|
||||||
|
them left with no reader; an engine's next large write failed, and in a
|
||||||
|
Python engine took the request it was reporting on with it. Each process
|
||||||
|
now writes to its own file in the agent's work directory, and an adopted
|
||||||
|
engine's log keeps what it wrote before the restart.
|
||||||
|
- **Single sign-on behind a balancer.** The cookie that carries a sign-in
|
||||||
|
across the identity provider was signed with a key of each process's own,
|
||||||
|
so with two control planes a callback that reached the other one was
|
||||||
|
refused. The key is now a secret in the store, the same on every control
|
||||||
|
plane.
|
||||||
|
- **Notifications sent once by two control planes.** Each kept its own
|
||||||
|
hour of quiet per event, so a budget spent on a team both served was
|
||||||
|
announced twice. The control plane that claims an event in the store
|
||||||
|
(migration 0043) sends it; with the store unreachable it is sent anyway.
|
||||||
|
- **A request turned away for a busy cluster says when to come back.** A
|
||||||
|
request that waited as long as it may (504) or found the queue full (503)
|
||||||
|
had no `Retry-After`, so `llmgw-kb` counted the passage as failed instead
|
||||||
|
of sending it again; both now say 10 seconds.
|
||||||
|
- The usage sums and exports count pictures (`images`), as the request
|
||||||
|
records did; pictures made before are added to the sums.
|
||||||
|
|
||||||
|
## v0.14.2 — 2026-10-10
|
||||||
|
|
||||||
|
Pictures: OpenAI's `/v1/images/generations`, made by ComfyUI on the
|
||||||
|
cluster's own cards. The agents install ComfyUI themselves, the gateway
|
||||||
|
prices pictures by the picture, and the portal has a page to make them.
|
||||||
|
Also: on-demand models that survive a failover, and knowledge-base
|
||||||
|
embeddings fixes.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- The store gains a column (`usage.images`); nothing to do.
|
||||||
|
- **ComfyUI is installed only where asked**: `llmgw ctl config set
|
||||||
|
engines.comfyui 0.39.2` has every node with an NVIDIA GPU build it (about
|
||||||
|
10 GB under `/opt/llmgw/engines`). Nodes need this release to install it
|
||||||
|
or run engine `comfyui`.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Pictures at `/v1/images/generations`**, OpenAI's shape, through the same
|
||||||
|
key, allowlist, limit, admission and guardrail checks as any request. A
|
||||||
|
model with profiles on engine `comfyui` is placed on a GPU node like any
|
||||||
|
other. A ComfyUI server outside the cluster can be an external model with
|
||||||
|
`api: comfyui` instead. The workflow is a built-in (`flux-schnell`, the
|
||||||
|
default, or `flux-dev`) or a JSON file in ComfyUI's API format, with
|
||||||
|
`{{prompt}}`, `{{width}}`, `{{seed}}` and the like where the request's
|
||||||
|
values go. Requests may also send `seed`, `steps`, `negative_prompt` and
|
||||||
|
`guidance`. The seed used comes back in `x-llmgw-seed`. Pictures come back
|
||||||
|
as `b64_json`; ComfyUI's workflow is stripped from each PNG, and a client
|
||||||
|
that leaves takes its workflow off the queue. `/v1/models` says `images`
|
||||||
|
for such a model.
|
||||||
|
- **Engine `comfyui`.** ComfyUI asks for no key, so the agent starts it
|
||||||
|
behind `llmgw comfyui-gate`. ComfyUI listens on the loopback only. The
|
||||||
|
gate listens on the replica's port, demands the replica's key, and lets
|
||||||
|
through only what the router asks. The model's `comfyui:` block names
|
||||||
|
the workflow and, for a source that is a set of files, the checkpoint.
|
||||||
|
- **The agents install ComfyUI** (`engines.comfyui`). The release's source is
|
||||||
|
checked against a digest llmgw holds, since GitHub publishes none, and a
|
||||||
|
version without one is refused. Its requirements are resolved as PyPI
|
||||||
|
stood the day after the release, with PyPI's CUDA 13 torch or PyTorch's
|
||||||
|
CUDA 12.9 one for an older driver, and the result is checked on the card.
|
||||||
|
A ComfyUI installed by hand is named by `engines.comfyui.dir` in
|
||||||
|
agent.yaml.
|
||||||
|
- **A price per picture**: `per_image` in `auth.pricing`, on top of the
|
||||||
|
prompt's tokens. A model priced only per picture is not free, so a spent
|
||||||
|
budget still stops it. Each usage row keeps how many pictures were made,
|
||||||
|
shown by `llmgw ctl request`.
|
||||||
|
- **`/images` on the portal**, beside the chat, for the models that make
|
||||||
|
pictures. The chat no longer offers them. The page offers size, how many,
|
||||||
|
seed, steps and a negative prompt, and shows each picture with its seed
|
||||||
|
and a download link. Nothing is kept but the usage.
|
||||||
|
- **An embedding model's `query_prefix`**, put before the question a
|
||||||
|
knowledge-base search embeds and never before documents, for Qwen3-Embedding
|
||||||
|
and models trained like it.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **An awake on-demand model survived no restart or change of leader of the
|
||||||
|
control plane.** A control plane that had heard no requests read every
|
||||||
|
on-demand replica its nodes reported as wanted by nobody, and drained them
|
||||||
|
all. An adopted replica now gets a whole idle timeout.
|
||||||
|
- `ctl models` said `not placed` for an on-demand model that had been woken
|
||||||
|
and was serving; it now says `ready (on demand)`.
|
||||||
|
- A batch of embeddings was refused as one over-long prompt when the
|
||||||
|
passages together exceeded the context, though each one fit. The longest
|
||||||
|
input is what has to fit.
|
||||||
|
- The chat's kept conversation sat in the browser under one key for
|
||||||
|
everybody, so the next person to sign in on the same browser opened the
|
||||||
|
last one's conversation. It is kept per account, and signing out clears
|
||||||
|
what the pages stored.
|
||||||
|
|
||||||
|
## v0.14.1 — 2026-10-09
|
||||||
|
|
||||||
|
Adapters trained on the nodes from a knowledge base and kept private to
|
||||||
|
their owners; the cluster's and the agents' settings edited in one place;
|
||||||
|
the tenancy tree and keys over the admin API and in Terraform; knowledge
|
||||||
|
bases that hold up with many documents; the firewall setting on every
|
||||||
|
machine.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- **Nodes** need this release to follow `agents.*` in the cluster's
|
||||||
|
settings; until a node is upgraded its agent.yaml is what it runs on.
|
||||||
|
- **A separate mirror machine and store members that run nothing else**
|
||||||
|
follow the firewall setting once what reads it is installed: run `sudo
|
||||||
|
llmgw mirror init` on the mirror again with the flags it was set up with,
|
||||||
|
and `llmgw server init --patroni` again for the store's members. A control
|
||||||
|
plane set up before v0.12.0 needs `sudo llmgw firewall sync --install`.
|
||||||
|
- **The store's PostgreSQL memory** applies after each member is restarted,
|
||||||
|
one at a time, the leader last; until then it runs as before.
|
||||||
|
- The store gains tables and columns; nothing to do. Existing knowledge-base
|
||||||
|
collections get their full-text index when a control plane starts, and
|
||||||
|
keep `vector` storage; only new collections use `halfvec`, and only on
|
||||||
|
pgvector 0.7 or later.
|
||||||
|
- Cached knowledge-base passages in vLLM are made again once, under the new
|
||||||
|
`cache_salt`.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -41,6 +837,61 @@ APIs change between minors without a deprecation period.
|
|||||||
that would not load are refused on save, and set aside on a node rather
|
that would not load are refused on save, and set aside on a node rather
|
||||||
than keep its agent from starting. Nodes need this release to follow
|
than keep its agent from starting. Nodes need this release to follow
|
||||||
them.
|
them.
|
||||||
|
- **LoRA adapters trained on the nodes** (BACKLOG F4): `llmgw ctl kb dataset`
|
||||||
|
writes question/answer pairs from a collection with a model of the
|
||||||
|
cluster. `llmgw ctl train start` trains an adapter on a node with its
|
||||||
|
installed vLLM's torch and a small overlay, on a card with enough memory
|
||||||
|
free (beside an engine, if it leaves that much), converting to GGUF for
|
||||||
|
llama.cpp. `llmgw ctl train publish` keeps it on the mirror (which now
|
||||||
|
accepts uploads) and makes it the model's adapter.
|
||||||
|
- **Adapters with owners.** An adapter's `owner` (org, team, project, user
|
||||||
|
or key) is the only part of the tree that sees it; to anyone else it is a
|
||||||
|
model that does not exist. An adapter trained on a knowledge base belongs
|
||||||
|
to a part of the tree that may read it, and is never made public.
|
||||||
|
- The knowledge bases' vLLM `cache_salt` follows the collections searched,
|
||||||
|
not the organization, so two teams of one organization no longer share
|
||||||
|
cached passages.
|
||||||
|
- **Organizations, teams, projects, API keys and memberships in the admin
|
||||||
|
API**: `/api/v1/orgs` (the tree), `POST /api/v1/teams` and `projects`,
|
||||||
|
`PATCH` to rename or move, `DELETE` for what is empty; `/api/v1/keys` to
|
||||||
|
issue (the key is in the answer, once), list and revoke; `/api/v1/users`
|
||||||
|
and `/api/v1/users/{id}/memberships`. They were only `llmgw org`, `keys`
|
||||||
|
and `users` on a control plane before. The Terraform provider manages them
|
||||||
|
as `llmgw_org`, `llmgw_team`, `llmgw_project`, `llmgw_key` (the key in a
|
||||||
|
sensitive `secret`) and `llmgw_membership`.
|
||||||
|
- **The firewall setting reaches every machine.** A separate mirror and the
|
||||||
|
store's members follow `firewall.enabled` and `firewall_networks` as nodes
|
||||||
|
and control planes did: the leading control plane tells the mirror over
|
||||||
|
its API, and keeps the state in the store for members that run no llmgw,
|
||||||
|
which a timer on each reads. The mirror also lets in the network of every
|
||||||
|
node the leader knows. `mirror init` and `server init --patroni` install
|
||||||
|
what follows it; run them again on a cluster set up before.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The store's PostgreSQL is sized to its machine's memory.** Before, it ran
|
||||||
|
on PostgreSQL's defaults (128MB cache) under Patroni, or on 96MB on one
|
||||||
|
machine. Now each member gets a share of its own RAM, a smaller one where
|
||||||
|
a control plane or agent shares the machine, and the share is read again
|
||||||
|
every time `server init --patroni` runs. The new cache is applied by
|
||||||
|
restarting one member at a time, the leader last.
|
||||||
|
- **Knowledge-base searches hold up with many documents.** New collections
|
||||||
|
store their index as `halfvec` where pgvector is 0.7 or later (half the
|
||||||
|
memory, and embeddings up to 4000 dimensions instead of 2000); older
|
||||||
|
collections, and clusters on pgvector 0.6, keep `vector`. Each collection
|
||||||
|
has its own full-text index, as it has its own vector index, made for
|
||||||
|
existing collections when the control plane starts. The text arm ranks by
|
||||||
|
the cheap measure first and scores only those candidates.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- A knowledge-base search asking for more than 10 passages
|
||||||
|
(`X-Llmgw-Knowledge-K`) got at most 40 candidates per collection from the
|
||||||
|
vector index, whatever it asked for: pgvector's `hnsw.ef_search` is now
|
||||||
|
raised to the number wanted, up to 1000.
|
||||||
|
- `llmgw server backup` and `restore` against a replicated store over TLS
|
||||||
|
failed looking for `~/.postgresql/root.crt`: pg_dump was given
|
||||||
|
`sslmode=verify-full` without the cluster's authority.
|
||||||
|
|
||||||
## v0.14.0 — 2026-10-08
|
## v0.14.0 — 2026-10-08
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
|||||||
| v0.1.0 | 0 | Skeleton and sim-engine | released |
|
| v0.1.0 | 0 | Skeleton and sim-engine | released |
|
||||||
| v0.2.0 | 1 | Static router (filter/score/pick, streaming proxy) | released |
|
| v0.2.0 | 1 | Static router (filter/score/pick, streaming proxy) | released |
|
||||||
| v0.3.0 | 1.5 | Admission control (priority bands) | released |
|
| v0.3.0 | 1.5 | Admission control (priority bands) | released |
|
||||||
| v0.4.0 | 2 | Agent: GPU/IB discovery, engine supervision | released |
|
| v0.4.0 | 2 | Agent: GPU/IB discovery, engine supervision | released as part of v0.5.0 |
|
||||||
| v0.5.0 | 3 | Control plane: catalog, placement, reconciler, ctl | released |
|
| v0.5.0 | 3 | Control plane: catalog, placement, reconciler, ctl | released |
|
||||||
| v0.6.0 | 4 | Multi-node placement and fabric awareness | released |
|
| v0.6.0 | 4 | Multi-node placement and fabric awareness | released |
|
||||||
| v0.7.0 | — | Conversations, agent memory, disk management | released |
|
| v0.7.0 | — | Conversations, agent memory, disk management | released |
|
||||||
@@ -40,8 +40,9 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
|||||||
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | 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.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
|
||||||
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
||||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released |
|
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves; v0.14.6: setup that explains every part and lets it be chosen, the gateway on all three HA machines; v0.14.7: the judge sized for its machine; v0.14.8: sign-in with Active Directory, several keys for one outside provider; v0.14.9: sign-in set up from the console, a node's console that shows the machine) |
|
||||||
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
|
| v0.15.0 | — | The company's MCP tools and VS Code through the gateway; guardrails that ask outside services and put redacted values back; answers to similar questions | released |
|
||||||
|
| v1.0.0 | 5 | What phase 5 has left: precise KV routing proven on vLLM, the block cost picker, defragmentation across NVLink domains, prefill/decode disaggregation | planned |
|
||||||
|
|
||||||
As of v0.6.0 a cluster runs unattended: agents report their hardware, the
|
As of v0.6.0 a cluster runs unattended: agents report their hardware, the
|
||||||
control plane places replicas — including ones larger than a single machine —
|
control plane places replicas — including ones larger than a single machine —
|
||||||
@@ -50,6 +51,53 @@ budgets and rate limits decide who may use any of it.
|
|||||||
|
|
||||||
Since v0.11.0 (each release's detail is in [CHANGELOG.md](CHANGELOG.md)):
|
Since v0.11.0 (each release's detail is in [CHANGELOG.md](CHANGELOG.md)):
|
||||||
|
|
||||||
|
- **Setup that explains, and high availability that is whole** (v0.14.6).
|
||||||
|
`sudo llmgw` lists the parts of an install, each with what it is for and
|
||||||
|
what is missing without it, and every one can be turned off or set: the
|
||||||
|
starter model and its context, the mirror's directory, the judge's
|
||||||
|
categories and what it does when it does not answer, limits, the
|
||||||
|
portal's language, pictures and 3D. With three machines each runs the
|
||||||
|
control plane, with a floating address on the leader, and any one can
|
||||||
|
be lost: on three VMs, cutting the store primary's power lost no
|
||||||
|
request. One ComfyUI serves an environment's models, pictures, 3D and
|
||||||
|
workflows are MCP tools, and `/v1/messages` gives a model's reasoning as
|
||||||
|
thinking blocks.
|
||||||
|
- **Picture and 3D models that add themselves** (v0.14.5). Where a node's
|
||||||
|
card fits flux-schnell or triposr and the catalog makes nothing of that
|
||||||
|
kind, the leader adds it on demand, so a new cluster's portal has its
|
||||||
|
Images and 3D pages; the cluster page and the console show each preset
|
||||||
|
and add it by hand, and `presets.auto` turns it off. Stable Fast 3D's
|
||||||
|
colours can be brought to the picture's (`albedo_match`), and every mesh
|
||||||
|
carries its whole licence.
|
||||||
|
- **Any ComfyUI workflow, and models by name** (v0.14.4). `POST
|
||||||
|
/v1/workflows/runs` runs a ComfyUI model's workflow as a job, its inputs
|
||||||
|
(text, numbers, choices, images, masks, audio, video, files) declared in
|
||||||
|
the catalog and listed in `/v1/models`. `llmgw ctl model add triposr` or
|
||||||
|
`flux-schnell` adds a tested model with no YAML; a profile may name the
|
||||||
|
card it needs instead of a class, and ComfyUI now installs on V100s. A
|
||||||
|
licence carries what a machine can match, and a person can leave
|
||||||
|
licences out on the profile page so no key of theirs reaches such a
|
||||||
|
model. A 3D job waiting for the one card says what holds it and for
|
||||||
|
about how long.
|
||||||
|
- **3D models as jobs** (v0.14.3). `POST /v1/3d/generations` answers with a
|
||||||
|
job, asked after until its mesh is ready to download. ComfyUI makes it on
|
||||||
|
the cluster's own cards, TripoSR being the tested model. The job says how
|
||||||
|
far along it is, the model's licence is written into the GLB, and
|
||||||
|
`per_mesh` prices it. People make them at `/3d` on the portal; `llmgw ctl
|
||||||
|
3d` shows every owner's jobs. A model profile may also load weights of
|
||||||
|
its own, and an admission band may wait longer than the rest, for bulk
|
||||||
|
work.
|
||||||
|
- **Pictures from ComfyUI** (v0.14.2). `POST /v1/images/generations` is made
|
||||||
|
by ComfyUI, behind a gate that demands the replica's key, as an engine the
|
||||||
|
agents install from its source checked against a digest. `per_image`
|
||||||
|
prices it, and people make pictures at `/images` on the portal.
|
||||||
|
- **Adapters, and the tenancy tree over the API** (v0.14.1). `llmgw ctl
|
||||||
|
kb dataset` and `llmgw ctl train` make a LoRA adapter from a collection
|
||||||
|
on a node's spare GPU memory, kept on the mirror and seen only by its
|
||||||
|
owner. Organizations, teams, projects, keys and memberships are in the
|
||||||
|
admin API and the Terraform provider. The agents' settings are edited
|
||||||
|
with the cluster's, on the portal too, and the firewall setting reaches
|
||||||
|
every machine.
|
||||||
- **Knowledge bases** (v0.14.0). People put a company's documents in from
|
- **Knowledge bases** (v0.14.0). People put a company's documents in from
|
||||||
their own machines with `llmgw-kb`, a separate download for Windows,
|
their own machines with `llmgw-kb`, a separate download for Windows,
|
||||||
macOS and Linux that reads PDF, Office, OpenDocument, mail and many more
|
macOS and Linux that reads PDF, Office, OpenDocument, mail and many more
|
||||||
@@ -136,6 +184,8 @@ Every page is also in the binary: `llmgw docs`, or **Manual** in the console.
|
|||||||
cluster that exists
|
cluster that exists
|
||||||
- [docs/kubernetes.md](docs/kubernetes.md) — the control plane on
|
- [docs/kubernetes.md](docs/kubernetes.md) — the control plane on
|
||||||
Kubernetes with the Helm chart, and why GPU nodes stay outside it
|
Kubernetes with the Helm chart, and why GPU nodes stay outside it
|
||||||
|
- [docs/kong.md](docs/kong.md) — llmgw behind Kong Gateway, or Kong behind
|
||||||
|
llmgw: the configuration, what Kong counts, and which to choose
|
||||||
- [docs/fabrics.md](docs/fabrics.md) — InfiniBand fabrics and NVLink: what
|
- [docs/fabrics.md](docs/fabrics.md) — InfiniBand fabrics and NVLink: what
|
||||||
llmgw needs of them, checking a node with `llmgw doctor`, and what to look at
|
llmgw needs of them, checking a node with `llmgw doctor`, and what to look at
|
||||||
when a replica spanning nodes will not run
|
when a replica spanning nodes will not run
|
||||||
@@ -210,6 +260,33 @@ Add `--json` to any command for machine-readable output.
|
|||||||
says nothing about which of six constraints bit, so it prints every candidate
|
says nothing about which of six constraints bit, so it prints every candidate
|
||||||
considered and the reason it was rejected.
|
considered and the reason it was rejected.
|
||||||
|
|
||||||
|
## What llmgw adds to a request
|
||||||
|
|
||||||
|
Measured with `llmgw ctl bench overhead`. It starts an engine that answers
|
||||||
|
at once and a gateway in front of it, in one process on loopback. It sends
|
||||||
|
the same load straight to the engine and through the gateway, and reports
|
||||||
|
what the gateway added. Run on an Apple M5 (10 CPUs, load about 2) on
|
||||||
|
2026-10-16, 10 s a step, 72,000 requests with none failed:
|
||||||
|
|
||||||
|
| Through the gateway | added at p50 | added at p99 |
|
||||||
|
|---|---|---|
|
||||||
|
| bare | 0.06–0.24 ms | 0.10–0.33 ms |
|
||||||
|
| with authentication and usage accounting | 0.05–0.32 ms | 0.09–0.37 ms |
|
||||||
|
| with the guardrails' pattern checks as well | 0.10–0.42 ms | 0.15–0.49 ms |
|
||||||
|
|
||||||
|
The range is across 100 to 2,000 requests a second. The overhead was
|
||||||
|
*lowest* at 2,000, most likely because a busier processor keeps its cores
|
||||||
|
awake. The guardrails' judge model is not in these figures: a judgment
|
||||||
|
takes seconds on CPUs (see [install.md](docs/install.md)) and is a model's
|
||||||
|
time, not the gateway's.
|
||||||
|
|
||||||
|
The command also prints the gateway's CPU per thousand requests. Gateway,
|
||||||
|
engine and load share one process there, so that figure is rough (0.04 to
|
||||||
|
0.65 s per thousand in the run above); the latency is the one to quote.
|
||||||
|
Figures from a VM on a busy host move with the host: run it where you will
|
||||||
|
deploy, and keep the load it prints beside the result. CI runs a one-second
|
||||||
|
version on every change (`TestTheGatewaysOverheadIsMeasured`).
|
||||||
|
|
||||||
## Running the router
|
## Running the router
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -237,8 +314,9 @@ within 1.24x of idle while 500 concurrent batch requests run, against 58x with
|
|||||||
admission off.
|
admission off.
|
||||||
|
|
||||||
Routing profiles select how endpoints are scored: `chat` favours session
|
Routing profiles select how endpoints are scored: `chat` favours session
|
||||||
affinity, `batch` favours the shortest queue, `long-context` favours free KV
|
affinity and the replica that has been answering soonest (its time to first
|
||||||
cache. Pick one per model in the config, or per request with the
|
token, smoothed), `batch` favours the shortest queue, `long-context` favours
|
||||||
|
free KV cache. Pick one per model in the config, or per request with the
|
||||||
`x-llmgw-profile` header.
|
`x-llmgw-profile` header.
|
||||||
|
|
||||||
## sim-engine
|
## sim-engine
|
||||||
|
|||||||
+502
-71
@@ -106,6 +106,8 @@ type AgentMessage struct {
|
|||||||
// *AgentMessage_FabricTest
|
// *AgentMessage_FabricTest
|
||||||
// *AgentMessage_Upgrade
|
// *AgentMessage_Upgrade
|
||||||
// *AgentMessage_KvEvents
|
// *AgentMessage_KvEvents
|
||||||
|
// *AgentMessage_TrainStatus
|
||||||
|
// *AgentMessage_TrainArtifact
|
||||||
Msg isAgentMessage_Msg `protobuf_oneof:"msg"`
|
Msg isAgentMessage_Msg `protobuf_oneof:"msg"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
@@ -220,6 +222,24 @@ func (x *AgentMessage) GetKvEvents() *KVEvents {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) GetTrainStatus() *TrainStatus {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Msg.(*AgentMessage_TrainStatus); ok {
|
||||||
|
return x.TrainStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *AgentMessage) GetTrainArtifact() *TrainArtifact {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Msg.(*AgentMessage_TrainArtifact); ok {
|
||||||
|
return x.TrainArtifact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
type isAgentMessage_Msg interface {
|
type isAgentMessage_Msg interface {
|
||||||
isAgentMessage_Msg()
|
isAgentMessage_Msg()
|
||||||
}
|
}
|
||||||
@@ -256,6 +276,14 @@ type AgentMessage_KvEvents struct {
|
|||||||
KvEvents *KVEvents `protobuf:"bytes,8,opt,name=kv_events,json=kvEvents,proto3,oneof"`
|
KvEvents *KVEvents `protobuf:"bytes,8,opt,name=kv_events,json=kvEvents,proto3,oneof"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AgentMessage_TrainStatus struct {
|
||||||
|
TrainStatus *TrainStatus `protobuf:"bytes,9,opt,name=train_status,json=trainStatus,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentMessage_TrainArtifact struct {
|
||||||
|
TrainArtifact *TrainArtifact `protobuf:"bytes,10,opt,name=train_artifact,json=trainArtifact,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
func (*AgentMessage_Register) isAgentMessage_Msg() {}
|
func (*AgentMessage_Register) isAgentMessage_Msg() {}
|
||||||
|
|
||||||
func (*AgentMessage_Heartbeat) isAgentMessage_Msg() {}
|
func (*AgentMessage_Heartbeat) isAgentMessage_Msg() {}
|
||||||
@@ -272,6 +300,10 @@ func (*AgentMessage_Upgrade) isAgentMessage_Msg() {}
|
|||||||
|
|
||||||
func (*AgentMessage_KvEvents) isAgentMessage_Msg() {}
|
func (*AgentMessage_KvEvents) isAgentMessage_Msg() {}
|
||||||
|
|
||||||
|
func (*AgentMessage_TrainStatus) isAgentMessage_Msg() {}
|
||||||
|
|
||||||
|
func (*AgentMessage_TrainArtifact) isAgentMessage_Msg() {}
|
||||||
|
|
||||||
// KVEvents is a change to what one vLLM replica's prefix cache holds, read
|
// KVEvents is a change to what one vLLM replica's prefix cache holds, read
|
||||||
// by the agent from the engine's KV cache events and sent on for the router's
|
// by the agent from the engine's KV cache events and sent on for the router's
|
||||||
// precise index (plan §7.5 tier 2).
|
// precise index (plan §7.5 tier 2).
|
||||||
@@ -392,6 +424,8 @@ type ControlMessage struct {
|
|||||||
// *ControlMessage_Firewall
|
// *ControlMessage_Firewall
|
||||||
// *ControlMessage_RestartAgent
|
// *ControlMessage_RestartAgent
|
||||||
// *ControlMessage_AgentSettings
|
// *ControlMessage_AgentSettings
|
||||||
|
// *ControlMessage_TrainStart
|
||||||
|
// *ControlMessage_TrainCancel
|
||||||
Msg isControlMessage_Msg `protobuf_oneof:"msg"`
|
Msg isControlMessage_Msg `protobuf_oneof:"msg"`
|
||||||
// trace is the W3C trace context (traceparent, and tracestate when there
|
// 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
|
// is one) of the control plane's span that sent this command, so what the
|
||||||
@@ -566,6 +600,24 @@ func (x *ControlMessage) GetAgentSettings() *AgentSettings {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *ControlMessage) GetTrainStart() *TrainStart {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Msg.(*ControlMessage_TrainStart); ok {
|
||||||
|
return x.TrainStart
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ControlMessage) GetTrainCancel() *TrainCancel {
|
||||||
|
if x != nil {
|
||||||
|
if x, ok := x.Msg.(*ControlMessage_TrainCancel); ok {
|
||||||
|
return x.TrainCancel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (x *ControlMessage) GetTrace() map[string]string {
|
func (x *ControlMessage) GetTrace() map[string]string {
|
||||||
if x != nil {
|
if x != nil {
|
||||||
return x.Trace
|
return x.Trace
|
||||||
@@ -633,6 +685,14 @@ type ControlMessage_AgentSettings struct {
|
|||||||
AgentSettings *AgentSettings `protobuf:"bytes,15,opt,name=agent_settings,json=agentSettings,proto3,oneof"`
|
AgentSettings *AgentSettings `protobuf:"bytes,15,opt,name=agent_settings,json=agentSettings,proto3,oneof"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ControlMessage_TrainStart struct {
|
||||||
|
TrainStart *TrainStart `protobuf:"bytes,16,opt,name=train_start,json=trainStart,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ControlMessage_TrainCancel struct {
|
||||||
|
TrainCancel *TrainCancel `protobuf:"bytes,17,opt,name=train_cancel,json=trainCancel,proto3,oneof"`
|
||||||
|
}
|
||||||
|
|
||||||
func (*ControlMessage_Start) isControlMessage_Msg() {}
|
func (*ControlMessage_Start) isControlMessage_Msg() {}
|
||||||
|
|
||||||
func (*ControlMessage_Stop) isControlMessage_Msg() {}
|
func (*ControlMessage_Stop) isControlMessage_Msg() {}
|
||||||
@@ -661,6 +721,10 @@ func (*ControlMessage_RestartAgent) isControlMessage_Msg() {}
|
|||||||
|
|
||||||
func (*ControlMessage_AgentSettings) isControlMessage_Msg() {}
|
func (*ControlMessage_AgentSettings) isControlMessage_Msg() {}
|
||||||
|
|
||||||
|
func (*ControlMessage_TrainStart) isControlMessage_Msg() {}
|
||||||
|
|
||||||
|
func (*ControlMessage_TrainCancel) isControlMessage_Msg() {}
|
||||||
|
|
||||||
// RestartAgent has the agent start again, as llmgw restart asks of every
|
// RestartAgent has the agent start again, as llmgw restart asks of every
|
||||||
// node: its engines keep serving and are taken over again.
|
// node: its engines keep serving and are taken over again.
|
||||||
type RestartAgent struct {
|
type RestartAgent struct {
|
||||||
@@ -4699,11 +4763,335 @@ func (x *LoraAdapter) GetSource() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrainStart has a node train a LoRA adapter (BACKLOG F4): on a base model's
|
||||||
|
// weights from its registry, on question/answer pairs, with a Python trainer
|
||||||
|
// the agent carries, in an environment it makes over an installed engine's.
|
||||||
|
// One at a time per node.
|
||||||
|
type TrainStart struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"`
|
||||||
|
// base_repo and base_revision name the base model's weights on its
|
||||||
|
// registry (Hugging Face): safetensors, not the GGUF a catalog may serve.
|
||||||
|
BaseRepo string `protobuf:"bytes,2,opt,name=base_repo,json=baseRepo,proto3" json:"base_repo,omitempty"`
|
||||||
|
BaseRevision string `protobuf:"bytes,3,opt,name=base_revision,json=baseRevision,proto3" json:"base_revision,omitempty"`
|
||||||
|
// data is the training pairs, NDJSON of {"q", "a"}.
|
||||||
|
Data []byte `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"`
|
||||||
|
// params is the trainer's settings as JSON: rank, alpha, epochs, lr,
|
||||||
|
// batch, max_len.
|
||||||
|
Params string `protobuf:"bytes,5,opt,name=params,proto3" json:"params,omitempty"`
|
||||||
|
// gpu_memory_mib is what the job needs of a card; the agent starts it
|
||||||
|
// only when one has that much free, which lets it run beside an engine.
|
||||||
|
GpuMemoryMib int32 `protobuf:"varint,6,opt,name=gpu_memory_mib,json=gpuMemoryMib,proto3" json:"gpu_memory_mib,omitempty"`
|
||||||
|
// gguf asks for the adapter in llama.cpp's format too.
|
||||||
|
Gguf bool `protobuf:"varint,7,opt,name=gguf,proto3" json:"gguf,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStart) Reset() {
|
||||||
|
*x = TrainStart{}
|
||||||
|
mi := &file_llmgw_v1_agent_proto_msgTypes[44]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStart) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*TrainStart) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *TrainStart) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_llmgw_v1_agent_proto_msgTypes[44]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use TrainStart.ProtoReflect.Descriptor instead.
|
||||||
|
func (*TrainStart) Descriptor() ([]byte, []int) {
|
||||||
|
return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{44}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStart) GetJobId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.JobId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStart) GetBaseRepo() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.BaseRepo
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStart) GetBaseRevision() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.BaseRevision
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStart) GetData() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Data
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStart) GetParams() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Params
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStart) GetGpuMemoryMib() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.GpuMemoryMib
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStart) GetGguf() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Gguf
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrainCancel stops a training job.
|
||||||
|
type TrainCancel struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainCancel) Reset() {
|
||||||
|
*x = TrainCancel{}
|
||||||
|
mi := &file_llmgw_v1_agent_proto_msgTypes[45]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainCancel) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*TrainCancel) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *TrainCancel) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_llmgw_v1_agent_proto_msgTypes[45]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use TrainCancel.ProtoReflect.Descriptor instead.
|
||||||
|
func (*TrainCancel) Descriptor() ([]byte, []int) {
|
||||||
|
return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{45}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainCancel) GetJobId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.JobId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrainStatus is how a training job is going, sent as it changes.
|
||||||
|
type TrainStatus struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"`
|
||||||
|
// state is preparing, running, done or failed.
|
||||||
|
State string `protobuf:"bytes,2,opt,name=state,proto3" json:"state,omitempty"`
|
||||||
|
Step int32 `protobuf:"varint,3,opt,name=step,proto3" json:"step,omitempty"`
|
||||||
|
Steps int32 `protobuf:"varint,4,opt,name=steps,proto3" json:"steps,omitempty"`
|
||||||
|
Loss float64 `protobuf:"fixed64,5,opt,name=loss,proto3" json:"loss,omitempty"`
|
||||||
|
// message is what is being done, or why it failed.
|
||||||
|
Message string `protobuf:"bytes,6,opt,name=message,proto3" json:"message,omitempty"`
|
||||||
|
// peak_mib is the most the job held of the card.
|
||||||
|
PeakMib int32 `protobuf:"varint,7,opt,name=peak_mib,json=peakMib,proto3" json:"peak_mib,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStatus) Reset() {
|
||||||
|
*x = TrainStatus{}
|
||||||
|
mi := &file_llmgw_v1_agent_proto_msgTypes[46]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStatus) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*TrainStatus) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *TrainStatus) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_llmgw_v1_agent_proto_msgTypes[46]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use TrainStatus.ProtoReflect.Descriptor instead.
|
||||||
|
func (*TrainStatus) Descriptor() ([]byte, []int) {
|
||||||
|
return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{46}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStatus) GetJobId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.JobId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStatus) GetState() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.State
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStatus) GetStep() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Step
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStatus) GetSteps() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Steps
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStatus) GetLoss() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Loss
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStatus) GetMessage() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Message
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainStatus) GetPeakMib() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.PeakMib
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrainArtifact is a piece of what a training job made, sent before its
|
||||||
|
// final status: a gzipped tar of the PEFT adapter (kind peft), or the GGUF
|
||||||
|
// adapter (kind gguf).
|
||||||
|
type TrainArtifact struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"`
|
||||||
|
Kind string `protobuf:"bytes,2,opt,name=kind,proto3" json:"kind,omitempty"`
|
||||||
|
Seq int32 `protobuf:"varint,3,opt,name=seq,proto3" json:"seq,omitempty"`
|
||||||
|
Data []byte `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"`
|
||||||
|
Last bool `protobuf:"varint,5,opt,name=last,proto3" json:"last,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainArtifact) Reset() {
|
||||||
|
*x = TrainArtifact{}
|
||||||
|
mi := &file_llmgw_v1_agent_proto_msgTypes[47]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainArtifact) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*TrainArtifact) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *TrainArtifact) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_llmgw_v1_agent_proto_msgTypes[47]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use TrainArtifact.ProtoReflect.Descriptor instead.
|
||||||
|
func (*TrainArtifact) Descriptor() ([]byte, []int) {
|
||||||
|
return file_llmgw_v1_agent_proto_rawDescGZIP(), []int{47}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainArtifact) GetJobId() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.JobId
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainArtifact) GetKind() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Kind
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainArtifact) GetSeq() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Seq
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainArtifact) GetData() []byte {
|
||||||
|
if x != nil {
|
||||||
|
return x.Data
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *TrainArtifact) GetLast() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.Last
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
var File_llmgw_v1_agent_proto protoreflect.FileDescriptor
|
var File_llmgw_v1_agent_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
const file_llmgw_v1_agent_proto_rawDesc = "" +
|
const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"\x14llmgw/v1/agent.proto\x12\bllmgw.v1\"\xb9\x03\n" +
|
"\x14llmgw/v1/agent.proto\x12\bllmgw.v1\"\xb7\x04\n" +
|
||||||
"\fAgentMessage\x120\n" +
|
"\fAgentMessage\x120\n" +
|
||||||
"\bregister\x18\x01 \x01(\v2\x12.llmgw.v1.RegisterH\x00R\bregister\x123\n" +
|
"\bregister\x18\x01 \x01(\v2\x12.llmgw.v1.RegisterH\x00R\bregister\x123\n" +
|
||||||
"\theartbeat\x18\x02 \x01(\v2\x13.llmgw.v1.HeartbeatH\x00R\theartbeat\x12-\n" +
|
"\theartbeat\x18\x02 \x01(\v2\x13.llmgw.v1.HeartbeatH\x00R\theartbeat\x12-\n" +
|
||||||
@@ -4714,7 +5102,10 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"\vfabric_test\x18\x06 \x01(\v2\x19.llmgw.v1.FabricTestReplyH\x00R\n" +
|
"\vfabric_test\x18\x06 \x01(\v2\x19.llmgw.v1.FabricTestReplyH\x00R\n" +
|
||||||
"fabricTest\x122\n" +
|
"fabricTest\x122\n" +
|
||||||
"\aupgrade\x18\a \x01(\v2\x16.llmgw.v1.UpgradeReplyH\x00R\aupgrade\x121\n" +
|
"\aupgrade\x18\a \x01(\v2\x16.llmgw.v1.UpgradeReplyH\x00R\aupgrade\x121\n" +
|
||||||
"\tkv_events\x18\b \x01(\v2\x12.llmgw.v1.KVEventsH\x00R\bkvEventsB\x05\n" +
|
"\tkv_events\x18\b \x01(\v2\x12.llmgw.v1.KVEventsH\x00R\bkvEvents\x12:\n" +
|
||||||
|
"\ftrain_status\x18\t \x01(\v2\x15.llmgw.v1.TrainStatusH\x00R\vtrainStatus\x12@\n" +
|
||||||
|
"\x0etrain_artifact\x18\n" +
|
||||||
|
" \x01(\v2\x17.llmgw.v1.TrainArtifactH\x00R\rtrainArtifactB\x05\n" +
|
||||||
"\x03msg\"\xb5\x01\n" +
|
"\x03msg\"\xb5\x01\n" +
|
||||||
"\bKVEvents\x12\x1d\n" +
|
"\bKVEvents\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
@@ -4725,7 +5116,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"clearFirst\x12\x16\n" +
|
"clearFirst\x12\x16\n" +
|
||||||
"\x06stored\x18\x04 \x03(\x06R\x06stored\x12\x18\n" +
|
"\x06stored\x18\x04 \x03(\x06R\x06stored\x12\x18\n" +
|
||||||
"\aremoved\x18\x05 \x03(\x06R\aremoved\x12\x18\n" +
|
"\aremoved\x18\x05 \x03(\x06R\aremoved\x12\x18\n" +
|
||||||
"\aproblem\x18\x06 \x01(\tR\aproblem\"\x83\a\n" +
|
"\aproblem\x18\x06 \x01(\tR\aproblem\"\xf8\a\n" +
|
||||||
"\x0eControlMessage\x12,\n" +
|
"\x0eControlMessage\x12,\n" +
|
||||||
"\x05start\x18\x01 \x01(\v2\x14.llmgw.v1.StartShardH\x00R\x05start\x12)\n" +
|
"\x05start\x18\x01 \x01(\v2\x14.llmgw.v1.StartShardH\x00R\x05start\x12)\n" +
|
||||||
"\x04stop\x18\x02 \x01(\v2\x13.llmgw.v1.StopShardH\x00R\x04stop\x12,\n" +
|
"\x04stop\x18\x02 \x01(\v2\x13.llmgw.v1.StopShardH\x00R\x04stop\x12,\n" +
|
||||||
@@ -4743,7 +5134,10 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"\x0econtrol_planes\x18\v \x01(\v2\x17.llmgw.v1.ControlPlanesH\x00R\rcontrolPlanes\x120\n" +
|
"\x0econtrol_planes\x18\v \x01(\v2\x17.llmgw.v1.ControlPlanesH\x00R\rcontrolPlanes\x120\n" +
|
||||||
"\bfirewall\x18\r \x01(\v2\x12.llmgw.v1.FirewallH\x00R\bfirewall\x12=\n" +
|
"\bfirewall\x18\r \x01(\v2\x12.llmgw.v1.FirewallH\x00R\bfirewall\x12=\n" +
|
||||||
"\rrestart_agent\x18\x0e \x01(\v2\x16.llmgw.v1.RestartAgentH\x00R\frestartAgent\x12@\n" +
|
"\rrestart_agent\x18\x0e \x01(\v2\x16.llmgw.v1.RestartAgentH\x00R\frestartAgent\x12@\n" +
|
||||||
"\x0eagent_settings\x18\x0f \x01(\v2\x17.llmgw.v1.AgentSettingsH\x00R\ragentSettings\x129\n" +
|
"\x0eagent_settings\x18\x0f \x01(\v2\x17.llmgw.v1.AgentSettingsH\x00R\ragentSettings\x127\n" +
|
||||||
|
"\vtrain_start\x18\x10 \x01(\v2\x14.llmgw.v1.TrainStartH\x00R\n" +
|
||||||
|
"trainStart\x12:\n" +
|
||||||
|
"\ftrain_cancel\x18\x11 \x01(\v2\x15.llmgw.v1.TrainCancelH\x00R\vtrainCancel\x129\n" +
|
||||||
"\x05trace\x18\f \x03(\v2#.llmgw.v1.ControlMessage.TraceEntryR\x05trace\x1a8\n" +
|
"\x05trace\x18\f \x03(\v2#.llmgw.v1.ControlMessage.TraceEntryR\x05trace\x1a8\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"TraceEntry\x12\x10\n" +
|
"TraceEntry\x12\x10\n" +
|
||||||
@@ -5150,7 +5544,32 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"\x05error\x18\x03 \x01(\tR\x05error\"9\n" +
|
"\x05error\x18\x03 \x01(\tR\x05error\"9\n" +
|
||||||
"\vLoraAdapter\x12\x12\n" +
|
"\vLoraAdapter\x12\x12\n" +
|
||||||
"\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" +
|
"\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" +
|
||||||
"\x06source\x18\x02 \x01(\tR\x06source2O\n" +
|
"\x06source\x18\x02 \x01(\tR\x06source\"\xcb\x01\n" +
|
||||||
|
"\n" +
|
||||||
|
"TrainStart\x12\x15\n" +
|
||||||
|
"\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x1b\n" +
|
||||||
|
"\tbase_repo\x18\x02 \x01(\tR\bbaseRepo\x12#\n" +
|
||||||
|
"\rbase_revision\x18\x03 \x01(\tR\fbaseRevision\x12\x12\n" +
|
||||||
|
"\x04data\x18\x04 \x01(\fR\x04data\x12\x16\n" +
|
||||||
|
"\x06params\x18\x05 \x01(\tR\x06params\x12$\n" +
|
||||||
|
"\x0egpu_memory_mib\x18\x06 \x01(\x05R\fgpuMemoryMib\x12\x12\n" +
|
||||||
|
"\x04gguf\x18\a \x01(\bR\x04gguf\"$\n" +
|
||||||
|
"\vTrainCancel\x12\x15\n" +
|
||||||
|
"\x06job_id\x18\x01 \x01(\tR\x05jobId\"\xad\x01\n" +
|
||||||
|
"\vTrainStatus\x12\x15\n" +
|
||||||
|
"\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x14\n" +
|
||||||
|
"\x05state\x18\x02 \x01(\tR\x05state\x12\x12\n" +
|
||||||
|
"\x04step\x18\x03 \x01(\x05R\x04step\x12\x14\n" +
|
||||||
|
"\x05steps\x18\x04 \x01(\x05R\x05steps\x12\x12\n" +
|
||||||
|
"\x04loss\x18\x05 \x01(\x01R\x04loss\x12\x18\n" +
|
||||||
|
"\amessage\x18\x06 \x01(\tR\amessage\x12\x19\n" +
|
||||||
|
"\bpeak_mib\x18\a \x01(\x05R\apeakMib\"t\n" +
|
||||||
|
"\rTrainArtifact\x12\x15\n" +
|
||||||
|
"\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x12\n" +
|
||||||
|
"\x04kind\x18\x02 \x01(\tR\x04kind\x12\x10\n" +
|
||||||
|
"\x03seq\x18\x03 \x01(\x05R\x03seq\x12\x12\n" +
|
||||||
|
"\x04data\x18\x04 \x01(\fR\x04data\x12\x12\n" +
|
||||||
|
"\x04last\x18\x05 \x01(\bR\x04last2O\n" +
|
||||||
"\fAgentService\x12?\n" +
|
"\fAgentService\x12?\n" +
|
||||||
"\aConnect\x12\x16.llmgw.v1.AgentMessage\x1a\x18.llmgw.v1.ControlMessage(\x010\x01B\x93\x01\n" +
|
"\aConnect\x12\x16.llmgw.v1.AgentMessage\x1a\x18.llmgw.v1.ControlMessage(\x010\x01B\x93\x01\n" +
|
||||||
"\fcom.llmgw.v1B\n" +
|
"\fcom.llmgw.v1B\n" +
|
||||||
@@ -5169,7 +5588,7 @@ func file_llmgw_v1_agent_proto_rawDescGZIP() []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var file_llmgw_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
var file_llmgw_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
|
||||||
var file_llmgw_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 52)
|
var file_llmgw_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 56)
|
||||||
var file_llmgw_v1_agent_proto_goTypes = []any{
|
var file_llmgw_v1_agent_proto_goTypes = []any{
|
||||||
(ShardStatus_State)(0), // 0: llmgw.v1.ShardStatus.State
|
(ShardStatus_State)(0), // 0: llmgw.v1.ShardStatus.State
|
||||||
(*AgentMessage)(nil), // 1: llmgw.v1.AgentMessage
|
(*AgentMessage)(nil), // 1: llmgw.v1.AgentMessage
|
||||||
@@ -5216,14 +5635,18 @@ var file_llmgw_v1_agent_proto_goTypes = []any{
|
|||||||
(*UpgradeAgent)(nil), // 42: llmgw.v1.UpgradeAgent
|
(*UpgradeAgent)(nil), // 42: llmgw.v1.UpgradeAgent
|
||||||
(*UpgradeReply)(nil), // 43: llmgw.v1.UpgradeReply
|
(*UpgradeReply)(nil), // 43: llmgw.v1.UpgradeReply
|
||||||
(*LoraAdapter)(nil), // 44: llmgw.v1.LoraAdapter
|
(*LoraAdapter)(nil), // 44: llmgw.v1.LoraAdapter
|
||||||
nil, // 45: llmgw.v1.ControlMessage.TraceEntry
|
(*TrainStart)(nil), // 45: llmgw.v1.TrainStart
|
||||||
nil, // 46: llmgw.v1.Register.LabelsEntry
|
(*TrainCancel)(nil), // 46: llmgw.v1.TrainCancel
|
||||||
nil, // 47: llmgw.v1.Register.EngineVersionsEntry
|
(*TrainStatus)(nil), // 47: llmgw.v1.TrainStatus
|
||||||
nil, // 48: llmgw.v1.GPU.NicPathsEntry
|
(*TrainArtifact)(nil), // 48: llmgw.v1.TrainArtifact
|
||||||
nil, // 49: llmgw.v1.IBPort.CountersEntry
|
nil, // 49: llmgw.v1.ControlMessage.TraceEntry
|
||||||
nil, // 50: llmgw.v1.Heartbeat.EngineVersionsEntry
|
nil, // 50: llmgw.v1.Register.LabelsEntry
|
||||||
nil, // 51: llmgw.v1.EngineWant.OfflineSha256Entry
|
nil, // 51: llmgw.v1.Register.EngineVersionsEntry
|
||||||
nil, // 52: llmgw.v1.EngineProfile.EnvEntry
|
nil, // 52: llmgw.v1.GPU.NicPathsEntry
|
||||||
|
nil, // 53: llmgw.v1.IBPort.CountersEntry
|
||||||
|
nil, // 54: llmgw.v1.Heartbeat.EngineVersionsEntry
|
||||||
|
nil, // 55: llmgw.v1.EngineWant.OfflineSha256Entry
|
||||||
|
nil, // 56: llmgw.v1.EngineProfile.EnvEntry
|
||||||
}
|
}
|
||||||
var file_llmgw_v1_agent_proto_depIdxs = []int32{
|
var file_llmgw_v1_agent_proto_depIdxs = []int32{
|
||||||
8, // 0: llmgw.v1.AgentMessage.register:type_name -> llmgw.v1.Register
|
8, // 0: llmgw.v1.AgentMessage.register:type_name -> llmgw.v1.Register
|
||||||
@@ -5234,62 +5657,66 @@ var file_llmgw_v1_agent_proto_depIdxs = []int32{
|
|||||||
41, // 5: llmgw.v1.AgentMessage.fabric_test:type_name -> llmgw.v1.FabricTestReply
|
41, // 5: llmgw.v1.AgentMessage.fabric_test:type_name -> llmgw.v1.FabricTestReply
|
||||||
43, // 6: llmgw.v1.AgentMessage.upgrade:type_name -> llmgw.v1.UpgradeReply
|
43, // 6: llmgw.v1.AgentMessage.upgrade:type_name -> llmgw.v1.UpgradeReply
|
||||||
2, // 7: llmgw.v1.AgentMessage.kv_events:type_name -> llmgw.v1.KVEvents
|
2, // 7: llmgw.v1.AgentMessage.kv_events:type_name -> llmgw.v1.KVEvents
|
||||||
30, // 8: llmgw.v1.ControlMessage.start:type_name -> llmgw.v1.StartShard
|
47, // 8: llmgw.v1.AgentMessage.train_status:type_name -> llmgw.v1.TrainStatus
|
||||||
33, // 9: llmgw.v1.ControlMessage.stop:type_name -> llmgw.v1.StopShard
|
48, // 9: llmgw.v1.AgentMessage.train_artifact:type_name -> llmgw.v1.TrainArtifact
|
||||||
34, // 10: llmgw.v1.ControlMessage.drain:type_name -> llmgw.v1.DrainShard
|
30, // 10: llmgw.v1.ControlMessage.start:type_name -> llmgw.v1.StartShard
|
||||||
35, // 11: llmgw.v1.ControlMessage.ack:type_name -> llmgw.v1.Ack
|
33, // 11: llmgw.v1.ControlMessage.stop:type_name -> llmgw.v1.StopShard
|
||||||
32, // 12: llmgw.v1.ControlMessage.remove_weights:type_name -> llmgw.v1.RemoveWeights
|
34, // 12: llmgw.v1.ControlMessage.drain:type_name -> llmgw.v1.DrainShard
|
||||||
24, // 13: llmgw.v1.ControlMessage.provision:type_name -> llmgw.v1.Provision
|
35, // 13: llmgw.v1.ControlMessage.ack:type_name -> llmgw.v1.Ack
|
||||||
36, // 14: llmgw.v1.ControlMessage.logs:type_name -> llmgw.v1.LogsRequest
|
32, // 14: llmgw.v1.ControlMessage.remove_weights:type_name -> llmgw.v1.RemoveWeights
|
||||||
38, // 15: llmgw.v1.ControlMessage.fabric_scan:type_name -> llmgw.v1.FabricScanRequest
|
24, // 15: llmgw.v1.ControlMessage.provision:type_name -> llmgw.v1.Provision
|
||||||
40, // 16: llmgw.v1.ControlMessage.fabric_test:type_name -> llmgw.v1.FabricTestRequest
|
36, // 16: llmgw.v1.ControlMessage.logs:type_name -> llmgw.v1.LogsRequest
|
||||||
42, // 17: llmgw.v1.ControlMessage.upgrade:type_name -> llmgw.v1.UpgradeAgent
|
38, // 17: llmgw.v1.ControlMessage.fabric_scan:type_name -> llmgw.v1.FabricScanRequest
|
||||||
7, // 18: llmgw.v1.ControlMessage.control_planes:type_name -> llmgw.v1.ControlPlanes
|
40, // 18: llmgw.v1.ControlMessage.fabric_test:type_name -> llmgw.v1.FabricTestRequest
|
||||||
5, // 19: llmgw.v1.ControlMessage.firewall:type_name -> llmgw.v1.Firewall
|
42, // 19: llmgw.v1.ControlMessage.upgrade:type_name -> llmgw.v1.UpgradeAgent
|
||||||
4, // 20: llmgw.v1.ControlMessage.restart_agent:type_name -> llmgw.v1.RestartAgent
|
7, // 20: llmgw.v1.ControlMessage.control_planes:type_name -> llmgw.v1.ControlPlanes
|
||||||
6, // 21: llmgw.v1.ControlMessage.agent_settings:type_name -> llmgw.v1.AgentSettings
|
5, // 21: llmgw.v1.ControlMessage.firewall:type_name -> llmgw.v1.Firewall
|
||||||
45, // 22: llmgw.v1.ControlMessage.trace:type_name -> llmgw.v1.ControlMessage.TraceEntry
|
4, // 22: llmgw.v1.ControlMessage.restart_agent:type_name -> llmgw.v1.RestartAgent
|
||||||
46, // 23: llmgw.v1.Register.labels:type_name -> llmgw.v1.Register.LabelsEntry
|
6, // 23: llmgw.v1.ControlMessage.agent_settings:type_name -> llmgw.v1.AgentSettings
|
||||||
12, // 24: llmgw.v1.Register.gpus:type_name -> llmgw.v1.GPU
|
45, // 24: llmgw.v1.ControlMessage.train_start:type_name -> llmgw.v1.TrainStart
|
||||||
14, // 25: llmgw.v1.Register.fabrics:type_name -> llmgw.v1.FabricAttachment
|
46, // 25: llmgw.v1.ControlMessage.train_cancel:type_name -> llmgw.v1.TrainCancel
|
||||||
47, // 26: llmgw.v1.Register.engine_versions:type_name -> llmgw.v1.Register.EngineVersionsEntry
|
49, // 26: llmgw.v1.ControlMessage.trace:type_name -> llmgw.v1.ControlMessage.TraceEntry
|
||||||
11, // 27: llmgw.v1.Register.running_shards:type_name -> llmgw.v1.RunningShard
|
50, // 27: llmgw.v1.Register.labels:type_name -> llmgw.v1.Register.LabelsEntry
|
||||||
15, // 28: llmgw.v1.Register.weights:type_name -> llmgw.v1.LocalWeights
|
12, // 28: llmgw.v1.Register.gpus:type_name -> llmgw.v1.GPU
|
||||||
10, // 29: llmgw.v1.Register.storage:type_name -> llmgw.v1.Storage
|
14, // 29: llmgw.v1.Register.fabrics:type_name -> llmgw.v1.FabricAttachment
|
||||||
9, // 30: llmgw.v1.Register.host:type_name -> llmgw.v1.Host
|
51, // 30: llmgw.v1.Register.engine_versions:type_name -> llmgw.v1.Register.EngineVersionsEntry
|
||||||
21, // 31: llmgw.v1.Register.imex:type_name -> llmgw.v1.IMEX
|
11, // 31: llmgw.v1.Register.running_shards:type_name -> llmgw.v1.RunningShard
|
||||||
0, // 32: llmgw.v1.RunningShard.state:type_name -> llmgw.v1.ShardStatus.State
|
15, // 32: llmgw.v1.Register.weights:type_name -> llmgw.v1.LocalWeights
|
||||||
27, // 33: llmgw.v1.RunningShard.compute:type_name -> llmgw.v1.Compute
|
10, // 33: llmgw.v1.Register.storage:type_name -> llmgw.v1.Storage
|
||||||
48, // 34: llmgw.v1.GPU.nic_paths:type_name -> llmgw.v1.GPU.NicPathsEntry
|
9, // 34: llmgw.v1.Register.host:type_name -> llmgw.v1.Host
|
||||||
13, // 35: llmgw.v1.GPU.mig:type_name -> llmgw.v1.MIGDevice
|
21, // 35: llmgw.v1.Register.imex:type_name -> llmgw.v1.IMEX
|
||||||
18, // 36: llmgw.v1.NodeLoad.shards:type_name -> llmgw.v1.ShardLoad
|
0, // 36: llmgw.v1.RunningShard.state:type_name -> llmgw.v1.ShardStatus.State
|
||||||
17, // 37: llmgw.v1.NodeLoad.gpus:type_name -> llmgw.v1.GPUSensor
|
27, // 37: llmgw.v1.RunningShard.compute:type_name -> llmgw.v1.Compute
|
||||||
49, // 38: llmgw.v1.IBPort.counters:type_name -> llmgw.v1.IBPort.CountersEntry
|
52, // 38: llmgw.v1.GPU.nic_paths:type_name -> llmgw.v1.GPU.NicPathsEntry
|
||||||
20, // 39: llmgw.v1.IBPort.subnet_managers:type_name -> llmgw.v1.SubnetManager
|
13, // 39: llmgw.v1.GPU.mig:type_name -> llmgw.v1.MIGDevice
|
||||||
10, // 40: llmgw.v1.Heartbeat.storage:type_name -> llmgw.v1.Storage
|
18, // 40: llmgw.v1.NodeLoad.shards:type_name -> llmgw.v1.ShardLoad
|
||||||
15, // 41: llmgw.v1.Heartbeat.weights:type_name -> llmgw.v1.LocalWeights
|
17, // 41: llmgw.v1.NodeLoad.gpus:type_name -> llmgw.v1.GPUSensor
|
||||||
50, // 42: llmgw.v1.Heartbeat.engine_versions:type_name -> llmgw.v1.Heartbeat.EngineVersionsEntry
|
53, // 42: llmgw.v1.IBPort.counters:type_name -> llmgw.v1.IBPort.CountersEntry
|
||||||
26, // 43: llmgw.v1.Heartbeat.provision:type_name -> llmgw.v1.ProvisionStatus
|
20, // 43: llmgw.v1.IBPort.subnet_managers:type_name -> llmgw.v1.SubnetManager
|
||||||
16, // 44: llmgw.v1.Heartbeat.load:type_name -> llmgw.v1.NodeLoad
|
10, // 44: llmgw.v1.Heartbeat.storage:type_name -> llmgw.v1.Storage
|
||||||
19, // 45: llmgw.v1.Heartbeat.ib_ports:type_name -> llmgw.v1.IBPort
|
15, // 45: llmgw.v1.Heartbeat.weights:type_name -> llmgw.v1.LocalWeights
|
||||||
14, // 46: llmgw.v1.Heartbeat.fabrics:type_name -> llmgw.v1.FabricAttachment
|
54, // 46: llmgw.v1.Heartbeat.engine_versions:type_name -> llmgw.v1.Heartbeat.EngineVersionsEntry
|
||||||
22, // 47: llmgw.v1.Heartbeat.gpu_fabric:type_name -> llmgw.v1.GPUFabricState
|
26, // 47: llmgw.v1.Heartbeat.provision:type_name -> llmgw.v1.ProvisionStatus
|
||||||
21, // 48: llmgw.v1.Heartbeat.imex:type_name -> llmgw.v1.IMEX
|
16, // 48: llmgw.v1.Heartbeat.load:type_name -> llmgw.v1.NodeLoad
|
||||||
25, // 49: llmgw.v1.Provision.engines:type_name -> llmgw.v1.EngineWant
|
19, // 49: llmgw.v1.Heartbeat.ib_ports:type_name -> llmgw.v1.IBPort
|
||||||
51, // 50: llmgw.v1.EngineWant.offline_sha256:type_name -> llmgw.v1.EngineWant.OfflineSha256Entry
|
14, // 50: llmgw.v1.Heartbeat.fabrics:type_name -> llmgw.v1.FabricAttachment
|
||||||
0, // 51: llmgw.v1.ShardStatus.state:type_name -> llmgw.v1.ShardStatus.State
|
22, // 51: llmgw.v1.Heartbeat.gpu_fabric:type_name -> llmgw.v1.GPUFabricState
|
||||||
29, // 52: llmgw.v1.ShardStatus.fetch:type_name -> llmgw.v1.FetchProgress
|
21, // 52: llmgw.v1.Heartbeat.imex:type_name -> llmgw.v1.IMEX
|
||||||
27, // 53: llmgw.v1.ShardStatus.compute:type_name -> llmgw.v1.Compute
|
25, // 53: llmgw.v1.Provision.engines:type_name -> llmgw.v1.EngineWant
|
||||||
31, // 54: llmgw.v1.StartShard.profile:type_name -> llmgw.v1.EngineProfile
|
55, // 54: llmgw.v1.EngineWant.offline_sha256:type_name -> llmgw.v1.EngineWant.OfflineSha256Entry
|
||||||
52, // 55: llmgw.v1.EngineProfile.env:type_name -> llmgw.v1.EngineProfile.EnvEntry
|
0, // 55: llmgw.v1.ShardStatus.state:type_name -> llmgw.v1.ShardStatus.State
|
||||||
44, // 56: llmgw.v1.EngineProfile.adapters:type_name -> llmgw.v1.LoraAdapter
|
29, // 56: llmgw.v1.ShardStatus.fetch:type_name -> llmgw.v1.FetchProgress
|
||||||
1, // 57: llmgw.v1.AgentService.Connect:input_type -> llmgw.v1.AgentMessage
|
27, // 57: llmgw.v1.ShardStatus.compute:type_name -> llmgw.v1.Compute
|
||||||
3, // 58: llmgw.v1.AgentService.Connect:output_type -> llmgw.v1.ControlMessage
|
31, // 58: llmgw.v1.StartShard.profile:type_name -> llmgw.v1.EngineProfile
|
||||||
58, // [58:59] is the sub-list for method output_type
|
56, // 59: llmgw.v1.EngineProfile.env:type_name -> llmgw.v1.EngineProfile.EnvEntry
|
||||||
57, // [57:58] is the sub-list for method input_type
|
44, // 60: llmgw.v1.EngineProfile.adapters:type_name -> llmgw.v1.LoraAdapter
|
||||||
57, // [57:57] is the sub-list for extension type_name
|
1, // 61: llmgw.v1.AgentService.Connect:input_type -> llmgw.v1.AgentMessage
|
||||||
57, // [57:57] is the sub-list for extension extendee
|
3, // 62: llmgw.v1.AgentService.Connect:output_type -> llmgw.v1.ControlMessage
|
||||||
0, // [0:57] is the sub-list for field type_name
|
62, // [62:63] is the sub-list for method output_type
|
||||||
|
61, // [61:62] is the sub-list for method input_type
|
||||||
|
61, // [61:61] is the sub-list for extension type_name
|
||||||
|
61, // [61:61] is the sub-list for extension extendee
|
||||||
|
0, // [0:61] is the sub-list for field type_name
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() { file_llmgw_v1_agent_proto_init() }
|
func init() { file_llmgw_v1_agent_proto_init() }
|
||||||
@@ -5306,6 +5733,8 @@ func file_llmgw_v1_agent_proto_init() {
|
|||||||
(*AgentMessage_FabricTest)(nil),
|
(*AgentMessage_FabricTest)(nil),
|
||||||
(*AgentMessage_Upgrade)(nil),
|
(*AgentMessage_Upgrade)(nil),
|
||||||
(*AgentMessage_KvEvents)(nil),
|
(*AgentMessage_KvEvents)(nil),
|
||||||
|
(*AgentMessage_TrainStatus)(nil),
|
||||||
|
(*AgentMessage_TrainArtifact)(nil),
|
||||||
}
|
}
|
||||||
file_llmgw_v1_agent_proto_msgTypes[2].OneofWrappers = []any{
|
file_llmgw_v1_agent_proto_msgTypes[2].OneofWrappers = []any{
|
||||||
(*ControlMessage_Start)(nil),
|
(*ControlMessage_Start)(nil),
|
||||||
@@ -5322,6 +5751,8 @@ func file_llmgw_v1_agent_proto_init() {
|
|||||||
(*ControlMessage_Firewall)(nil),
|
(*ControlMessage_Firewall)(nil),
|
||||||
(*ControlMessage_RestartAgent)(nil),
|
(*ControlMessage_RestartAgent)(nil),
|
||||||
(*ControlMessage_AgentSettings)(nil),
|
(*ControlMessage_AgentSettings)(nil),
|
||||||
|
(*ControlMessage_TrainStart)(nil),
|
||||||
|
(*ControlMessage_TrainCancel)(nil),
|
||||||
}
|
}
|
||||||
type x struct{}
|
type x struct{}
|
||||||
out := protoimpl.TypeBuilder{
|
out := protoimpl.TypeBuilder{
|
||||||
@@ -5329,7 +5760,7 @@ func file_llmgw_v1_agent_proto_init() {
|
|||||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_llmgw_v1_agent_proto_rawDesc), len(file_llmgw_v1_agent_proto_rawDesc)),
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_llmgw_v1_agent_proto_rawDesc), len(file_llmgw_v1_agent_proto_rawDesc)),
|
||||||
NumEnums: 1,
|
NumEnums: 1,
|
||||||
NumMessages: 52,
|
NumMessages: 56,
|
||||||
NumExtensions: 0,
|
NumExtensions: 0,
|
||||||
NumServices: 1,
|
NumServices: 1,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ message AgentMessage {
|
|||||||
FabricTestReply fabric_test = 6;
|
FabricTestReply fabric_test = 6;
|
||||||
UpgradeReply upgrade = 7;
|
UpgradeReply upgrade = 7;
|
||||||
KVEvents kv_events = 8;
|
KVEvents kv_events = 8;
|
||||||
|
TrainStatus train_status = 9;
|
||||||
|
TrainArtifact train_artifact = 10;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,6 +76,8 @@ message ControlMessage {
|
|||||||
Firewall firewall = 13;
|
Firewall firewall = 13;
|
||||||
RestartAgent restart_agent = 14;
|
RestartAgent restart_agent = 14;
|
||||||
AgentSettings agent_settings = 15;
|
AgentSettings agent_settings = 15;
|
||||||
|
TrainStart train_start = 16;
|
||||||
|
TrainCancel train_cancel = 17;
|
||||||
}
|
}
|
||||||
// trace is the W3C trace context (traceparent, and tracestate when there
|
// 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
|
// is one) of the control plane's span that sent this command, so what the
|
||||||
@@ -884,3 +888,55 @@ message LoraAdapter {
|
|||||||
string name = 1;
|
string name = 1;
|
||||||
string source = 2;
|
string source = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TrainStart has a node train a LoRA adapter (BACKLOG F4): on a base model's
|
||||||
|
// weights from its registry, on question/answer pairs, with a Python trainer
|
||||||
|
// the agent carries, in an environment it makes over an installed engine's.
|
||||||
|
// One at a time per node.
|
||||||
|
message TrainStart {
|
||||||
|
string job_id = 1;
|
||||||
|
// base_repo and base_revision name the base model's weights on its
|
||||||
|
// registry (Hugging Face): safetensors, not the GGUF a catalog may serve.
|
||||||
|
string base_repo = 2;
|
||||||
|
string base_revision = 3;
|
||||||
|
// data is the training pairs, NDJSON of {"q", "a"}.
|
||||||
|
bytes data = 4;
|
||||||
|
// params is the trainer's settings as JSON: rank, alpha, epochs, lr,
|
||||||
|
// batch, max_len.
|
||||||
|
string params = 5;
|
||||||
|
// gpu_memory_mib is what the job needs of a card; the agent starts it
|
||||||
|
// only when one has that much free, which lets it run beside an engine.
|
||||||
|
int32 gpu_memory_mib = 6;
|
||||||
|
// gguf asks for the adapter in llama.cpp's format too.
|
||||||
|
bool gguf = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrainCancel stops a training job.
|
||||||
|
message TrainCancel {
|
||||||
|
string job_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrainStatus is how a training job is going, sent as it changes.
|
||||||
|
message TrainStatus {
|
||||||
|
string job_id = 1;
|
||||||
|
// state is preparing, running, done or failed.
|
||||||
|
string state = 2;
|
||||||
|
int32 step = 3;
|
||||||
|
int32 steps = 4;
|
||||||
|
double loss = 5;
|
||||||
|
// message is what is being done, or why it failed.
|
||||||
|
string message = 6;
|
||||||
|
// peak_mib is the most the job held of the card.
|
||||||
|
int32 peak_mib = 7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrainArtifact is a piece of what a training job made, sent before its
|
||||||
|
// final status: a gzipped tar of the PEFT adapter (kind peft), or the GGUF
|
||||||
|
// adapter (kind gguf).
|
||||||
|
message TrainArtifact {
|
||||||
|
string job_id = 1;
|
||||||
|
string kind = 2;
|
||||||
|
int32 seq = 3;
|
||||||
|
bytes data = 4;
|
||||||
|
bool last = 5;
|
||||||
|
}
|
||||||
+37
-12
@@ -1,4 +1,4 @@
|
|||||||
# Two control planes
|
# More than one control plane
|
||||||
|
|
||||||
llmgw handles the hard half of this itself: only one control plane places work,
|
llmgw handles the hard half of this itself: only one control plane places work,
|
||||||
and agents find whichever one is leading. What it does not do is move client
|
and agents find whichever one is leading. What it does not do is move client
|
||||||
@@ -37,7 +37,7 @@ nothing in front of the two had moved.
|
|||||||
So something has to. The options, in the order most people should consider
|
So something has to. The options, in the order most people should consider
|
||||||
them:
|
them:
|
||||||
|
|
||||||
**A load balancer you already run.** Health-check `/ready` on each control
|
**A load balancer you already run.** Health-check `/leader` on each control
|
||||||
plane and send traffic to whichever answers. This is the right answer if you
|
plane and send traffic to whichever answers. This is the right answer if you
|
||||||
have one, and it needs nothing else from this directory.
|
have one, and it needs nothing else from this directory.
|
||||||
|
|
||||||
@@ -50,25 +50,50 @@ in whatever your resolvers actually honour rather than what you set, which on
|
|||||||
a bad day is minutes. Fine for a cluster people use interactively, wrong for
|
a bad day is minutes. Fine for a cluster people use interactively, wrong for
|
||||||
one an agent is driving.
|
one an agent is driving.
|
||||||
|
|
||||||
## Check `/ready`, not `/health`
|
## Check `/leader`, not `/health`
|
||||||
|
|
||||||
This is the one thing to get right, and the obvious choice is the wrong one.
|
This is the one thing to get right, and the obvious choice is the wrong one.
|
||||||
|
|
||||||
`/health` says the process is up. It says nothing about whether it can serve,
|
`/health` says the process is up. It says nothing about whether it leads,
|
||||||
and a standby is exactly that case: alive, answering, and holding no endpoints
|
and a standby is exactly that case: alive, answering, and holding no endpoints
|
||||||
at all — its reconciler placed nothing and it has no agent streams to adopt
|
at all — its reconciler placed nothing and it has no agent streams to adopt
|
||||||
from. A load balancer checking `/health` would send it half the traffic, and
|
from. A standby passes client requests on to the leader, so traffic sent to
|
||||||
half the traffic would be a 503.
|
one still works — until the leader is the machine that was lost, and there
|
||||||
|
is nobody to pass them to.
|
||||||
|
|
||||||
|
`/leader` is 200 on the control plane that leads and 503 on a standby. It is
|
||||||
|
what keepalived follows for the floating address, and what a load balancer
|
||||||
|
should send traffic by: to the one machine that answers 200.
|
||||||
|
|
||||||
|
`/ready` is narrower: 200 only when this process has somewhere to send a
|
||||||
|
request, so 503 on a standby and also on a leader with no model running yet.
|
||||||
|
Checking it takes the portal and the admin pages away with the models — on a
|
||||||
|
new cluster with no node yet, nobody can sign in to add one. Use it to stop
|
||||||
|
sending inference only, if your load balancer can tell the paths apart.
|
||||||
|
|
||||||
`/ready` says this process has somewhere to send a request. It is 503 on a
|
|
||||||
standby, 503 on a leader whose replicas have all failed, and 200 when there is
|
|
||||||
something to serve — which is the question a load balancer is actually asking.
|
|
||||||
Both are open, because a load balancer has no key.
|
Both are open, because a load balancer has no key.
|
||||||
|
|
||||||
During a failover neither passes for about the length of the lease handover:
|
During a failover nothing passes for about the length of the lease handover:
|
||||||
three seconds when the leader is stopped cleanly, up to the lease TTL when it
|
three seconds when the leader is stopped cleanly, up to the lease TTL when it
|
||||||
is not. That gap is real and there is no way to have it both ways — traffic
|
is not. A proxy that waits that long for a healthy machine rather than
|
||||||
cannot go to a control plane that has nothing to route to.
|
failing at once (Caddy's `lb_try_duration`) hides it from clients.
|
||||||
|
|
||||||
|
**Caddy**, in front of three control planes — the end of
|
||||||
|
[deploy/proxy/Caddyfile](../proxy/Caddyfile) has it with the rest of the
|
||||||
|
settings:
|
||||||
|
|
||||||
|
```
|
||||||
|
reverse_proxy 10.0.0.157:8080 10.0.0.158:8080 10.0.0.159:8080 {
|
||||||
|
lb_policy first
|
||||||
|
health_uri /leader
|
||||||
|
health_interval 2s
|
||||||
|
lb_try_duration 15s
|
||||||
|
flush_interval -1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
With a floating address the address already follows the leader: give Caddy
|
||||||
|
that one address instead.
|
||||||
|
|
||||||
Not the admin port, for a different reason: it serves pprof, and pprof can
|
Not the admin port, for a different reason: it serves pprof, and pprof can
|
||||||
dump the process heap, where API keys sit in plaintext.
|
dump the process heap, where API keys sit in plaintext.
|
||||||
|
|||||||
@@ -41,7 +41,9 @@ vrrp_instance llmgw {
|
|||||||
# MASTER on the machine you would rather serve; BACKUP on the other.
|
# MASTER on the machine you would rather serve; BACKUP on the other.
|
||||||
state MASTER
|
state MASTER
|
||||||
interface eth0
|
interface eth0
|
||||||
# Any number both machines agree on and nothing else on the segment uses.
|
# Any number both machines agree on and nothing else on the segment uses:
|
||||||
|
# another cluster's keepalived with the same one reads as an invalid
|
||||||
|
# password. `llmgw server add --vip` derives one from the address.
|
||||||
virtual_router_id 51
|
virtual_router_id 51
|
||||||
# The higher one wins while both are healthy. The weight above is enough
|
# The higher one wins while both are healthy. The weight above is enough
|
||||||
# to invert this when the preferred machine's gateway is not answering.
|
# to invert this when the preferred machine's gateway is not answering.
|
||||||
|
|||||||
@@ -21,11 +21,18 @@ scrape_configs:
|
|||||||
# The control plane's dedicated metrics port. Not its admin listener: that one
|
# The control plane's dedicated metrics port. Not its admin listener: that one
|
||||||
# is loopback because it also serves pprof, and pprof can dump the heap where
|
# is loopback because it also serves pprof, and pprof can dump the heap where
|
||||||
# API keys sit in plaintext.
|
# API keys sit in plaintext.
|
||||||
|
#
|
||||||
|
# Every control plane, one line each: the leader moves on restarts and the
|
||||||
|
# router's metrics are the leading one's, so scraping one alone leaves gaps
|
||||||
|
# — and alerts on them — whenever another leads.
|
||||||
- job_name: llmgw
|
- job_name: llmgw
|
||||||
static_configs:
|
static_configs:
|
||||||
- targets: ['10.0.0.140:9091']
|
- targets: ['10.0.0.140:9091']
|
||||||
labels:
|
labels:
|
||||||
instance: control-plane
|
instance: control-plane-140
|
||||||
|
# - targets: ['10.0.0.150:9091']
|
||||||
|
# labels:
|
||||||
|
# instance: control-plane-150
|
||||||
|
|
||||||
# Every machine of the cluster, for its node exporter: network and disk
|
# Every machine of the cluster, for its node exporter: network and disk
|
||||||
# traffic, filesystems, CPU time. The control plane lists them — nodes,
|
# traffic, filesystems, CPU time. The control plane lists them — nodes,
|
||||||
@@ -33,10 +40,15 @@ scrape_configs:
|
|||||||
# nothing edited here, and each carries its llmgw id as the node label.
|
# nothing edited here, and each carries its llmgw id as the node label.
|
||||||
# `llmgw node add` installs the exporter; on a machine added before that,
|
# `llmgw node add` installs the exporter; on a machine added before that,
|
||||||
# apt-get install prometheus-node-exporter.
|
# apt-get install prometheus-node-exporter.
|
||||||
|
# Asked of every control plane too: a standby lists only the control
|
||||||
|
# planes and the store, and they label every machine alike, so the same
|
||||||
|
# target from two of them is scraped once.
|
||||||
- job_name: nodes
|
- job_name: nodes
|
||||||
http_sd_configs:
|
http_sd_configs:
|
||||||
- url: http://10.0.0.140:9091/prometheus/targets
|
- url: http://10.0.0.140:9091/prometheus/targets
|
||||||
refresh_interval: 1m
|
refresh_interval: 1m
|
||||||
|
# - url: http://10.0.0.150:9091/prometheus/targets
|
||||||
|
# refresh_interval: 1m
|
||||||
|
|
||||||
- job_name: prometheus
|
- job_name: prometheus
|
||||||
static_configs:
|
static_configs:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ llm.example.com {
|
|||||||
max_size 32MB
|
max_size 32MB
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# One control plane. With more than one, see the end of this file.
|
||||||
reverse_proxy 127.0.0.1:8080 {
|
reverse_proxy 127.0.0.1:8080 {
|
||||||
# Tokens are written the moment they are generated. Without this a
|
# Tokens are written the moment they are generated. Without this a
|
||||||
# streamed reply can arrive in bursts, and a client measuring time to
|
# streamed reply can arrive in bursts, and a client measuring time to
|
||||||
@@ -39,3 +40,36 @@ llm.example.com {
|
|||||||
# Access log to stderr, which under systemd is the journal.
|
# Access log to stderr, which under systemd is the journal.
|
||||||
log
|
log
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# More than one control plane (server init --standbys, server add): one of
|
||||||
|
# them leads, the others stand by. Name them all and send to the one that
|
||||||
|
# leads — /leader is 200 there and 503 on a standby. Not /ready: that is 503
|
||||||
|
# on a leader with no model running yet, and would take the portal away with
|
||||||
|
# it. A standby would pass a request on to the leader anyway, but not while
|
||||||
|
# there is none, and the hop is for nothing.
|
||||||
|
#
|
||||||
|
# With a floating address (--vip) the address already follows the leader:
|
||||||
|
# give reverse_proxy that one address instead, and keep the rest as above.
|
||||||
|
#
|
||||||
|
# llm.example.com {
|
||||||
|
# request_body {
|
||||||
|
# max_size 32MB
|
||||||
|
# }
|
||||||
|
# reverse_proxy 10.0.0.157:8080 10.0.0.158:8080 10.0.0.159:8080 {
|
||||||
|
# # The first that is healthy: the leader, whichever it is.
|
||||||
|
# lb_policy first
|
||||||
|
# health_uri /leader
|
||||||
|
# health_interval 2s
|
||||||
|
# health_timeout 2s
|
||||||
|
# # While leadership moves — a few seconds when a leader is lost —
|
||||||
|
# # none is healthy; a request waits that long for one rather than
|
||||||
|
# # failing at once.
|
||||||
|
# lb_try_duration 15s
|
||||||
|
# lb_try_interval 250ms
|
||||||
|
# flush_interval -1
|
||||||
|
# transport http {
|
||||||
|
# response_header_timeout 15m
|
||||||
|
# }
|
||||||
|
# }
|
||||||
|
# log
|
||||||
|
# }
|
||||||
@@ -96,6 +96,12 @@ func titleOf(src, path string) string {
|
|||||||
//go:embed docs/admin-api.openapi.json
|
//go:embed docs/admin-api.openapi.json
|
||||||
var AdminOpenAPI []byte
|
var AdminOpenAPI []byte
|
||||||
|
|
||||||
|
// ClientOpenAPI is the client API described in OpenAPI 3.1, generated from
|
||||||
|
// internal/clientapi's route table and served at /v1/openapi.json.
|
||||||
|
//
|
||||||
|
//go:embed docs/client-api.openapi.json
|
||||||
|
var ClientOpenAPI []byte
|
||||||
|
|
||||||
// EvalSuites are the example quality suites under deploy/eval, which the
|
// EvalSuites are the example quality suites under deploy/eval, which the
|
||||||
// control plane runs by name when its eval directory has none of the name.
|
// control plane runs by name when its eval directory has none of the name.
|
||||||
//
|
//
|
||||||
|
|||||||
+16
-7
@@ -8,8 +8,9 @@ description is.
|
|||||||
anything they do can be done with `curl`, a generated client or the
|
anything they do can be done with `curl`, a generated client or the
|
||||||
[Terraform provider](../terraform-provider-llmgw/README.md). It adds and
|
[Terraform provider](../terraform-provider-llmgw/README.md). It adds and
|
||||||
removes models, scales them, cordons and upgrades nodes, sets capacity
|
removes models, scales them, cordons and upgrades nodes, sets capacity
|
||||||
quotas, attaches guardrail policies, and reads the fleet, usage and the audit
|
quotas, attaches guardrail policies, adds organizations, teams and projects,
|
||||||
log.
|
issues and revokes API keys, puts people in the tree, and reads the fleet,
|
||||||
|
usage and the audit log.
|
||||||
|
|
||||||
## Reaching it
|
## Reaching it
|
||||||
|
|
||||||
@@ -99,6 +100,14 @@ curl -s "${H[@]}" -X POST $API/models/qwen-small/scale -d '{"min": 0, "max": 2,
|
|||||||
# A team's capacity quota.
|
# A team's capacity quota.
|
||||||
curl -s "${H[@]}" -X PUT $API/quotas -d '{"scope": "team:Acme/ml", "limit": {"gpus": 8}}'
|
curl -s "${H[@]}" -X PUT $API/quotas -d '{"scope": "team:Acme/ml", "limit": {"gpus": 8}}'
|
||||||
|
|
||||||
|
# An organization, a team in it and a project in that; then a key for the
|
||||||
|
# project. The answer holds the key once: only its hash is kept.
|
||||||
|
curl -s "${H[@]}" -X POST $API/orgs -d '{"name": "Acme"}'
|
||||||
|
curl -s "${H[@]}" -X POST $API/teams -d '{"name": "ml", "in": "Acme"}'
|
||||||
|
curl -s "${H[@]}" -X POST $API/projects -d '{"name": "search", "in": "Acme/ml"}'
|
||||||
|
curl -s "${H[@]}" -X POST $API/keys -d '{"name": "ci", "project": "Acme/ml/search", "expires_in": "720h"}'
|
||||||
|
curl -s "${H[@]}" -X POST $API/users/ayse@example.com/memberships -d '{"target": "team:Acme/ml"}'
|
||||||
|
|
||||||
# Remove the model; a DELETE carries the content type too.
|
# Remove the model; a DELETE carries the content type too.
|
||||||
curl -s "${H[@]}" -X DELETE $API/models/qwen-small
|
curl -s "${H[@]}" -X DELETE $API/models/qwen-small
|
||||||
```
|
```
|
||||||
@@ -149,11 +158,11 @@ go generate ./internal/docgen
|
|||||||
|
|
||||||
## What it does not cover
|
## What it does not cover
|
||||||
|
|
||||||
- **Organizations, teams, projects, people and API keys.** These live in the
|
- **Making people's accounts.** People sign up, are invited, or come from
|
||||||
store and are managed with `llmgw org`, `llmgw users` and `llmgw keys` on the
|
the identity provider (OIDC, SCIM); the API lists them and puts them in
|
||||||
control plane, or in the web console's `/admin` pages on the client port
|
the tree, and `llmgw users` approves, disables and deletes them. Key
|
||||||
behind a session. The admin API reads the tree only where a quota or a
|
lifetime limits and spending limits are `llmgw keys ttl` and `llmgw keys
|
||||||
policy names a part of it.
|
limits`.
|
||||||
- **The client-facing API** (`/v1/chat/completions` and the rest), which is
|
- **The client-facing API** (`/v1/chat/completions` and the rest), which is
|
||||||
OpenAI's and Anthropic's, on `listen`, with API keys.
|
OpenAI's and Anthropic's, on `listen`, with API keys.
|
||||||
- **The rest of the admin listener**: `/metrics`, `/health`, the dashboard
|
- **The rest of the admin listener**: `/metrics`, `/health`, the dashboard
|
||||||
|
|||||||
+3073
-1
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
+78
-2
@@ -38,6 +38,14 @@ export ANTHROPIC_BASE_URL=https://llm.example.com
|
|||||||
export ANTHROPIC_API_KEY=$LLMGW_API_KEY
|
export ANTHROPIC_API_KEY=$LLMGW_API_KEY
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### The API described
|
||||||
|
|
||||||
|
Every endpoint is described in OpenAPI 3.1 at `/v1/openapi.json`. It needs
|
||||||
|
no key, so a client generator can be pointed at it. `/docs/api` shows it in
|
||||||
|
Swagger UI, where *Authorize* takes a key and *Try it out* sends a request to
|
||||||
|
the gateway. People with a part in running the gateway also find the admin
|
||||||
|
API's document there.
|
||||||
|
|
||||||
## A first request
|
## A first request
|
||||||
|
|
||||||
### curl
|
### curl
|
||||||
@@ -526,6 +534,73 @@ curl https://llm.example.com/mcp \
|
|||||||
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
|
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Pictures, 3D models, vision and workflows.** Where the gateway has the
|
||||||
|
models for them, the same server offers more tools — only those it has a
|
||||||
|
model for:
|
||||||
|
|
||||||
|
| Tool | Does |
|
||||||
|
|---|---|
|
||||||
|
| `generate_image` | A picture from a prompt. `style` adds what a picture for a mesh wants: `concept` (one object, three-quarter view, grey background), `side` (an exact side view), `texture` (a seamless tile) or `none`. Answers the PNG's `file_id`, `url` and `seed`, with a 512 px preview to look at. |
|
||||||
|
| `generate_mesh` | A GLB from a picture — `image` is a `file-…` id (a `generate_image` result, say), a data URL or base64 — or from a `prompt`, for which a concept picture is made first. `seed`, `resolution`, `texture_resolution`, and any other input the model's workflow declares in `inputs`. Answers the GLB's `file_id`, `url` and licence. |
|
||||||
|
| `review_images` | Asks a vision model about one to four pictures (each shrunk to 1280 px on its long side); `json_schema` makes it answer that JSON. |
|
||||||
|
| `run_workflow` | Runs a ComfyUI workflow from the inputs its catalog entry declares, and answers its result file. |
|
||||||
|
| `job_status` | Where a mesh or a workflow run stands, and its file when done; `wait_seconds` waits for it. |
|
||||||
|
| `gateway_status` | The picture, 3D, vision and workflow models, whether each is loaded on a GPU now, a workflow's inputs, and your last jobs. |
|
||||||
|
|
||||||
|
Each tool makes its request of the gateway under your key, so your
|
||||||
|
allowlist, limits, left-out licences and budget apply, and it is charged
|
||||||
|
and listed like any other request. What it makes is kept as your file for
|
||||||
|
as long as job results are: fetch it from `url` with the same key (`curl -H
|
||||||
|
"Authorization: Bearer $LLMGW_API_KEY" -o rock.glb <url>`). A tool server
|
||||||
|
over HTTP cannot write to your disk, so an agent that needs the file
|
||||||
|
locally — to import it in Blender — fetches it, or goes through a small
|
||||||
|
local bridge that does.
|
||||||
|
|
||||||
|
A picture can be uploaded once and named by its id after: `POST /v1/files`
|
||||||
|
with `purpose=vision` (or `user_data`) keeps it, for as long as job results
|
||||||
|
are kept, as a file of yours that `generate_mesh`, `review_images` and a
|
||||||
|
workflow's file inputs take as `file-…`. A call is at most 64 MB.
|
||||||
|
|
||||||
|
A model that is not loaded is waited for: a picture or a look at one whose
|
||||||
|
model is starting, or waiting for another to give up the GPU, waits as a
|
||||||
|
mesh does, saying so, rather than failing with "model loading".
|
||||||
|
|
||||||
|
A mesh takes from half a minute to a few minutes. A client that takes the
|
||||||
|
answer as a stream (`Accept: text/event-stream`, as Claude Code does) is
|
||||||
|
answered that way: `notifications/progress` for the call's
|
||||||
|
`progressToken` while it runs — queued, waiting for the GPU, the node and
|
||||||
|
its percent — then the result, and the call waits up to ten minutes. One
|
||||||
|
answered in one piece waits 50 seconds; a job not done by then is answered
|
||||||
|
with its `job_id`, to ask after with `job_status`. `wait_seconds` (up to
|
||||||
|
900) sets either.
|
||||||
|
|
||||||
|
```
|
||||||
|
claude mcp add --transport http llmgw https://llm.example.com/mcp \
|
||||||
|
--header "Authorization: Bearer $LLMGW_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Your company's tools.** Where an administrator has registered the
|
||||||
|
company's own MCP servers (Jira, a file server, a database) and your key is
|
||||||
|
granted their tools, the same `/mcp` lists them too, each as `server.tool`
|
||||||
|
(`jira.search`). Give your agent only llmgw's `/mcp` and your key: the
|
||||||
|
servers' own credentials stay with llmgw. A tool your key is not granted is
|
||||||
|
not listed, and calling it by name is refused. Each call is in the audit
|
||||||
|
log under your key: which tool, the size of what was sent, how it ended and
|
||||||
|
how long it took, never what it said.
|
||||||
|
|
||||||
|
### Licences you leave out
|
||||||
|
|
||||||
|
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
|
||||||
|
it allows commercial use and the regions it grants nothing in). On your
|
||||||
|
profile page, under **Licences**, you can leave licences out: one by one,
|
||||||
|
every licence that forbids commercial use, or every licence that grants
|
||||||
|
nothing in a region you name (`EU` for the union). The rules cover models
|
||||||
|
added later too. From then on those models are not listed to your keys and
|
||||||
|
a request for one is refused with 403 and the code `licence_excluded`,
|
||||||
|
naming the choice that left it out — on every key you own and on the
|
||||||
|
portal's pages, whatever a key's own allowlist says. A change reaches keys
|
||||||
|
already in use within a minute.
|
||||||
|
|
||||||
### Headers llmgw sends back
|
### Headers llmgw sends back
|
||||||
|
|
||||||
| Header | Says |
|
| Header | Says |
|
||||||
@@ -545,9 +620,10 @@ its usual exception with the gateway's message in it. The ones worth knowing:
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 400 | Besides a malformed request: a prompt too long for the model, or a guardrail policy refused it (the code `content_policy_violation`). |
|
| 400 | Besides a malformed request: a prompt too long for the model, or a guardrail policy refused it (the code `content_policy_violation`). |
|
||||||
| 401 | No key, a mistyped one, or one that was revoked or has expired. |
|
| 401 | No key, a mistyped one, or one that was revoked or has expired. |
|
||||||
| 403 | The key may not use that model. |
|
| 403 | The key may not use that model; with the code `licence_excluded`, its licence is one you left out on your profile page. |
|
||||||
| 429 | A rate limit or budget of the key, its project or its team was reached. `Retry-After` says when to try again, and is absent when waiting will not help — a spent monthly budget. |
|
| 429 | A rate limit or budget of the key, its project or its team was reached. `Retry-After` says when to try again, and is absent when waiting will not help — a spent monthly budget. |
|
||||||
| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the request waited as long as it may. The SDKs retry by themselves; a name that is not served will not start to be. |
|
| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the queue is full. The SDKs retry by themselves; a name that is not served will not start to be. |
|
||||||
|
| 504 | Every replica was busy for as long as the key's band may wait. `Retry-After` says when to send it again. |
|
||||||
|
|
||||||
## Priority
|
## Priority
|
||||||
|
|
||||||
|
|||||||
+39
-4
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
`sudo llmgw`, with nothing after it, on a machine llmgw runs on. On a machine
|
`sudo llmgw`, with nothing after it, on a machine llmgw runs on. On a machine
|
||||||
with nothing installed it opens the setup screens; on a control plane, the
|
with nothing installed it opens the setup screens; on a control plane, the
|
||||||
console described here; on a node, the node's own console, which edits
|
console described here; on a node, the node's own console, which shows
|
||||||
`agent.yaml`. Everything on these screens can also be done by command — each
|
what runs on that machine and edits `agent.yaml`. Everything on these screens can also be done by command — each
|
||||||
screen says which — so a script never needs them and a person never needs
|
screen says which — so a script never needs them and a person never needs
|
||||||
to remember the commands.
|
to remember the commands.
|
||||||
|
|
||||||
@@ -246,6 +246,28 @@ long a key made there may live, and is the expiry of one made without any
|
|||||||
Limits are requests and tokens a minute, a budget per day or month, and the
|
Limits are requests and tokens a minute, a budget per day or month, and the
|
||||||
models allowed. Each level only narrows what is above it.
|
models allowed. Each level only narrows what is above it.
|
||||||
|
|
||||||
|
**Sign-in** — how people get in besides a password kept here: single
|
||||||
|
sign-on, Active Directory and provisioning, each on or off, with the
|
||||||
|
directory's last sync (how many people, accounts kept in step, suspended,
|
||||||
|
reinstated).
|
||||||
|
- **E** sets up Entra ID. Give the tenant ID, the client ID and secret,
|
||||||
|
and the groups for administrators and operators. A provider other than
|
||||||
|
Entra takes its issuer URL in place of the tenant. The form shows the
|
||||||
|
redirect address to register.
|
||||||
|
- **D** sets up Active Directory: the servers, where people are, the
|
||||||
|
service account and its password, the domain's CA certificate as a PEM
|
||||||
|
file on this machine, the group whose members may sign in, and groups
|
||||||
|
for roles and teams.
|
||||||
|
- Before it is kept, the directory is tried from the control plane: each
|
||||||
|
server reached and bound, how many people may sign in, how many are in
|
||||||
|
each group. If any step fails, nothing is kept and the steps are shown.
|
||||||
|
**T** tries the settings in force.
|
||||||
|
- On the provisioning row, Enter makes a SCIM token, shown once.
|
||||||
|
- The password, the client secret and the token are kept in the store,
|
||||||
|
sealed by the master key when one is set. The settings name them as
|
||||||
|
`store:auth/…`, so each control plane has them with no file put on it.
|
||||||
|
The CA is written into the settings themselves.
|
||||||
|
|
||||||
**Guardrails…** on a row lists the guardrail policies server.yaml defines.
|
**Guardrails…** on a row lists the guardrail policies server.yaml defines.
|
||||||
Each policy is marked in one of three ways:
|
Each policy is marked in one of three ways:
|
||||||
|
|
||||||
@@ -335,5 +357,18 @@ to restart it.
|
|||||||
|
|
||||||
## The node's console
|
## The node's console
|
||||||
|
|
||||||
On a node, `sudo llmgw` opens `agent.yaml` in the same settings view, and the
|
On a node, `sudo llmgw` opens on **Overview**: what this machine is doing, read
|
||||||
manual. The cluster is watched and changed from a control plane.
|
on the machine itself, so it works without an admin token and while the control
|
||||||
|
plane is out of reach. Refreshed every three seconds, it shows
|
||||||
|
|
||||||
|
- llmgw-agent's state and since when, and the version of llmgw;
|
||||||
|
- each control plane the agent dials, from `agent.yaml` and the list the
|
||||||
|
leader last sent, and whether it can be reached;
|
||||||
|
- each GPU: how busy, its memory, its heat, and the models on it;
|
||||||
|
- the engines running here, from the agent's own records: replica, model,
|
||||||
|
engine, GPUs, port, whether the process is still there, and for how long;
|
||||||
|
- the free space where weights and engines are kept;
|
||||||
|
- the agent's warnings and errors of the last hour.
|
||||||
|
|
||||||
|
Below it, `agent.yaml` in the same settings view, and the manual. The cluster
|
||||||
|
is watched and changed from a control plane.
|
||||||
+3
-1
@@ -72,7 +72,9 @@ each answer that may be reused, as the client got it, and a hash of the
|
|||||||
request it answers, in the gateway process's memory — never in the store,
|
request it answers, in the gateway process's memory — never in the store,
|
||||||
never on disk — for the project's TTL or until pushed out by newer ones
|
never on disk — for the project's TTL or until pushed out by newer ones
|
||||||
(`router.cache.max_bytes`), and gone with a restart. Never under a `no_store`
|
(`router.cache.max_bytes`), and gone with a restart. Never under a `no_store`
|
||||||
policy. Forgetting a person does not reach into it; the answers age out by
|
policy. A project that reuses answers to similar questions (`--similar`)
|
||||||
|
also has each question's last user message embedded, and the vector kept
|
||||||
|
beside its answer, in the same memory and for as long. Forgetting a person does not reach into it; the answers age out by
|
||||||
the TTL. The store keeps only the setting, and a usage record's mark that it
|
the TTL. The store keeps only the setting, and a usage record's mark that it
|
||||||
was answered from the cache.
|
was answered from the cache.
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,21 @@ guard:
|
|||||||
value is the same placeholder throughout a request).
|
value is the same placeholder throughout a request).
|
||||||
- **`block`** stops it.
|
- **`block`** stops it.
|
||||||
|
|
||||||
|
**Putting them back.** With `restore: true` in a policy's `input`, the
|
||||||
|
values a request had redacted are put back in its answer: the model reads
|
||||||
|
"write to [PERSON_1] at [EMAIL_1]", the client reads the name and address
|
||||||
|
it sent. Streamed answers too — a placeholder split across two events is
|
||||||
|
held until it is whole. Only that request's own values, only into its
|
||||||
|
answer; the map is kept in memory for the request and nowhere else. The
|
||||||
|
output rules then read the answer as the client will, so a policy that
|
||||||
|
redacts personal data in answers still does.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
input:
|
||||||
|
pii: {email: redact, phone: redact}
|
||||||
|
restore: true
|
||||||
|
```
|
||||||
|
|
||||||
**Shapes.**
|
**Shapes.**
|
||||||
|
|
||||||
- `system_prompt` is put first.
|
- `system_prompt` is put first.
|
||||||
@@ -258,6 +273,39 @@ themselves.
|
|||||||
context rather than overflowing it and going unjudged.
|
context rather than overflowing it and going unjudged.
|
||||||
- **Cache:** the same text is not asked about twice within five minutes.
|
- **Cache:** the same text is not asked about twice within five minutes.
|
||||||
|
|
||||||
|
## An outside service
|
||||||
|
|
||||||
|
A site required to use a particular guardrail — Azure Content Safety,
|
||||||
|
Lakera Guard, NeMo Guardrails, a classifier of its own — names it in a
|
||||||
|
policy's `services`. It is asked over HTTP beside the patterns and before
|
||||||
|
the judge; its "unsafe" blocks or flags like theirs, and a service that does
|
||||||
|
not answer is what the policy's `fail` says.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
policies:
|
||||||
|
strict:
|
||||||
|
fail: closed
|
||||||
|
services:
|
||||||
|
- name: lakera
|
||||||
|
url: https://api.lakera.ai/v2/guard
|
||||||
|
header_files: {Authorization: /etc/llmgw/secrets/lakera} # "Bearer …", read each call
|
||||||
|
body: '{"messages": [{"role": "user", "content": {{json .Text}}}]}'
|
||||||
|
blocked: flagged # where the answer says unsafe
|
||||||
|
reason: breakdown.0.detector_type
|
||||||
|
on: both # input, output or both
|
||||||
|
action: block # or flag
|
||||||
|
timeout: 2s
|
||||||
|
```
|
||||||
|
|
||||||
|
`body` is a template given `.Text` (`{{json .Text}}` writes it as a JSON
|
||||||
|
string; empty sends `{"input": …}`). `blocked` and `reason` are dotted paths
|
||||||
|
into the JSON answer, numbers for list items; true, a number above zero, a
|
||||||
|
non-empty list or a string other than "false" is unsafe. On input the
|
||||||
|
service reads the latest user turn; on output the whole answer, once, at its
|
||||||
|
end — a streamed answer released a sentence at a time is then stopped or
|
||||||
|
flagged as the judge's is. The rule recorded is `service:<name>:<reason>`.
|
||||||
|
The service is told the text only, never who sent it.
|
||||||
|
|
||||||
## Where a policy applies
|
## Where a policy applies
|
||||||
|
|
||||||
`guard.default` applies everywhere. Others are attached to a part of the
|
`guard.default` applies everywhere. Others are attached to a part of the
|
||||||
|
|||||||
+729
-36
@@ -89,13 +89,36 @@ CGO_ENABLED=0 go build -o llmgw ./cmd/llmgw
|
|||||||
|
|
||||||
**The short way is the screens.** On the machine that will be the control
|
**The short way is the screens.** On the machine that will be the control
|
||||||
plane, run `sudo llmgw` with nothing after it. With nothing installed it asks
|
plane, run `sudo llmgw` with nothing after it. With nothing installed it asks
|
||||||
whether this is one machine or three for high availability; for three, it asks
|
whether this is one machine or three for high availability, then lists what
|
||||||
for the other two — address, user, and an ssh key, the ssh agent, or a
|
an install is made of, each part with what it is for and what is missing
|
||||||
password, and the sudo password if sudo asks for one; or, for a machine
|
without it:
|
||||||
nothing may log into, a join code to run there with `sudo llmgw join <code>`,
|
|
||||||
which dials out to this one on port 7444 — checks all three before
|
| Part | | Its settings (Enter) |
|
||||||
installing anything (system, room, clock, ports, internet, whether they reach
|
|---|---|---|
|
||||||
one another), shows the plan, and installs. Passwords are held in memory for
|
| Control plane | always | the administrator, other names for the certificate, the portal's language, the sign-up form |
|
||||||
|
| Store | always | SQLite or PostgreSQL on this machine; with three machines, PostgreSQL under Patroni on the other two |
|
||||||
|
| Control plane on all three | three machines, on | a floating address for whichever leads |
|
||||||
|
| Model mirror | on | here, and in which directory, or on another machine |
|
||||||
|
| This machine as a node | on for one machine | |
|
||||||
|
| Starter model | on for one machine | which: Qwen2.5 0.5B, 1.5B or 3B, Qwen3 4B, 8B or 14B, or any GGUF on Hugging Face; its context |
|
||||||
|
| Pictures and 3D | off | |
|
||||||
|
| Guardrails' judge | on | refuse or serve flagged when it does not answer; copies; how long it is waited for; what it refuses |
|
||||||
|
| Firewall | on | |
|
||||||
|
| Notifications | off | the webhooks |
|
||||||
|
| Limits and keys | always | requests and tokens a minute; the longest a key lives |
|
||||||
|
| Taking turns on a card | always | how long an on-demand model goes unused, and is safe after loading |
|
||||||
|
| Offline bundle | off | the bundle and its sha256 |
|
||||||
|
|
||||||
|
Space turns a part on or off; Enter opens its settings, which say more. What
|
||||||
|
cannot go together is said before anything is done — a starter model with no
|
||||||
|
mirror to fetch it into — and closing the message opens the setting that is
|
||||||
|
wrong. For three machines it then asks for the other two — address, user, and
|
||||||
|
an ssh key, the ssh agent, or a password, and the sudo password if sudo asks
|
||||||
|
for one; or, for a machine nothing may log into, a join code to run there
|
||||||
|
with `sudo llmgw join <code>`, which dials out to this one on port 7444 —
|
||||||
|
checks all three before installing anything (system, room, clock, ports,
|
||||||
|
internet, whether they reach one another). Last it shows the plan, part by
|
||||||
|
part, what is left out and what that costs, and installs. Passwords are held in memory for
|
||||||
the run only; a sudo password lets the user sudo without one until the install
|
the run only; a sudo password lets the user sudo without one until the install
|
||||||
finishes, and is then taken back. At the end it shows the administrator's
|
finishes, and is then taken back. At the end it shows the administrator's
|
||||||
password and the command that does the same from a script.
|
password and the command that does the same from a script.
|
||||||
@@ -129,15 +152,24 @@ That is the whole step. In order, it:
|
|||||||
`nvidia-smi` finds any, on CPU otherwise — and waits for it to install its
|
`nvidia-smi` finds any, on CPU otherwise — and waits for it to install its
|
||||||
engine
|
engine
|
||||||
9. closes llmgw's internal ports to everything but the cluster's networks
|
9. closes llmgw's internal ports to everything but the cluster's networks
|
||||||
10. fetches Qwen3Guard-Gen 0.6B, the guardrails' judge, and runs two
|
10. fetches Qwen3Guard-Gen 0.6B, the guardrails' judge, and runs it on
|
||||||
replicas on the CPUs of nodes with room — this one, or the first that
|
CPUs: one replica on up to four cores when this machine is the only
|
||||||
join ([guardrails.md](guardrails.md#a-judge-model) has one on a GPU too)
|
node, two on two cores each on the nodes of a larger cluster ([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
|
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
|
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.
|
machine without room for it is told so within two minutes, and why.
|
||||||
|
|
||||||
`--no-mirror` and `--no-node` leave out 7 and 8, `--no-firewall` 9,
|
`--no-mirror` and `--no-node` leave out 7 and 8, `--no-firewall` 9,
|
||||||
`--no-guard-model` 10 and `--no-model` 11. An NVIDIA card with no working
|
`--no-guard-model` 10 and `--no-model` 11. Every setting the screens ask for
|
||||||
|
is a flag too, and the plan shows the command with them: `--starter qwen3-8b`
|
||||||
|
(or `hf:<owner>/<repo>/<file>.gguf`) and `--starter-context`, `--mirror-dir`,
|
||||||
|
`--judge-fail open`, `--judge-categories`, `--judge-replicas`,
|
||||||
|
`--judge-budget`, `--language tr`, `--signup-open`, `--default-rpm`,
|
||||||
|
`--default-tpm`, `--max-key-ttl`, `--media` (pictures and 3D: ComfyUI on the
|
||||||
|
nodes, and `presets.auto`), `--evict-after-idle` and `--min-lifetime`. They
|
||||||
|
go into `server.yaml` as init writes it, and become the cluster's first
|
||||||
|
settings when it starts; a `server.yaml` already there is never overwritten,
|
||||||
|
so run again they change nothing — the settings page does. An NVIDIA card with no working
|
||||||
driver is reported rather than silently ignored; install the driver and run
|
driver is reported rather than silently ignored; install the driver and run
|
||||||
init again to serve on it.
|
init again to serve on it.
|
||||||
|
|
||||||
@@ -155,15 +187,43 @@ demonstration one:
|
|||||||
| Requests | every key limited (60 a minute, 200 000 tokens a minute) and priced, so budgets bind |
|
| Requests | every key limited (60 a minute, 200 000 tokens a minute) and priced, so budgets bind |
|
||||||
| Guardrails | the `baseline` policy on every request: secrets in prompts and answers refused, card numbers masked, injection attempts recorded, and the judge refusing violence, crime, weapons, child abuse, privacy violations, hate, self-harm and sexual content, in prompts and answers |
|
| Guardrails | the `baseline` policy on every request: secrets in prompts and answers refused, card numbers masked, injection attempts recorded, and the judge refusing violence, crime, weapons, child abuse, privacy violations, hate, self-harm and sexual content, in prompts and answers |
|
||||||
|
|
||||||
The judge runs as two replicas, each on a CPU — about half a second for a
|
The judge runs on CPUs. A judgment of about 280 tokens took 2.3 s on four
|
||||||
short text on any CPU with AVX2 — and reads a streamed answer every 600
|
cores, 4.2 s on two and 8.0 s on one (a 4-core VM). It also reads a
|
||||||
characters. The policy fails **closed**: a request the judge cannot read
|
streamed answer every 600 characters. The policy fails **closed**: a request
|
||||||
within 5 seconds is refused with 503 rather than served unjudged, and
|
the judge cannot read within 5 seconds is refused with 503 rather than
|
||||||
`judge_unavailable` goes to the notification webhooks. Two replicas are what
|
served unjudged, and `judge_unavailable` goes to the notification webhooks.
|
||||||
make that bearable; on one small machine, or a VM without AVX2 (see
|
So a judge gets cores of its own: on one machine one replica on up to four,
|
||||||
|
and on a larger cluster two replicas of two cores each, spread over its nodes
|
||||||
|
so that losing one leaves the other. On a machine of fewer than four cores,
|
||||||
|
a busy one, or a VM without AVX2 (see
|
||||||
[step 6](#6-setting-the-nodes-cpu-model-if-it-is-a-vm)), the judge is the
|
[step 6](#6-setting-the-nodes-cpu-model-if-it-is-a-vm)), the judge is the
|
||||||
first thing to fail, so `fail: open` — serve it, recorded as flagged — or
|
first thing to fail. The choices there are a longer `latency_budget`,
|
||||||
`--no-guard-model` is the choice there. Defamation, specialized advice,
|
`fail: open` (serve the request, recorded as flagged), the judge on a GPU,
|
||||||
|
or `--no-guard-model`.
|
||||||
|
|
||||||
|
init times the judge once it is up. If a judgment takes more than half the
|
||||||
|
wait, or more than the wait when several come at once, it says so before the
|
||||||
|
next steps, with what to do. The wait itself is not changed without being
|
||||||
|
asked. `llmgw ctl guard judge check` does the same at any time:
|
||||||
|
|
||||||
|
```
|
||||||
|
qwen3guard-0.6b, 2 replica(s) ready, a request waits 5.0 s for it
|
||||||
|
alone: 2.4, 2.3, 2.3 s
|
||||||
|
4 at once: slowest 6.1 s; about 0.7 judgments a second, so about 0.3 requests a second with short answers
|
||||||
|
tight: a busy moment passes the budget
|
||||||
|
```
|
||||||
|
|
||||||
|
In a three-machine install the judges are better on machines of their own.
|
||||||
|
On a 4-core control plane that was also a node, 38 of 60 requests under load
|
||||||
|
were refused for want of a judgment. With the judges on a node of their own
|
||||||
|
(4 cores, two judges of two cores each), 2 were refused. The setup screen asks
|
||||||
|
for up to two **judge machines** after the store's two, or give
|
||||||
|
`--judge-node user@host` to `server init`, once for each. Each is added as a
|
||||||
|
node labelled `llmgw.role: judge`, and the judge's profile selects that
|
||||||
|
label, so the judges run only there and nothing else does. Four cores and
|
||||||
|
8 GB are enough for two judges.
|
||||||
|
|
||||||
|
Defamation, specialized advice,
|
||||||
intellectual property and elections are left out of what it refuses: they
|
intellectual property and elections are left out of what it refuses: they
|
||||||
catch too much ordinary work. [guardrails.md](guardrails.md) has the rest.
|
catch too much ordinary work. [guardrails.md](guardrails.md) has the rest.
|
||||||
|
|
||||||
@@ -201,6 +261,16 @@ this by running the same command again, on the control plane that holds the
|
|||||||
authority: etcd is rebuilt on TLS with Patroni in maintenance mode, so
|
authority: etcd is rebuilt on TLS with Patroni in maintenance mode, so
|
||||||
PostgreSQL serves throughout and no failover happens.
|
PostgreSQL serves throughout and no failover happens.
|
||||||
|
|
||||||
|
Each member's PostgreSQL is sized to the machine's memory, read each time
|
||||||
|
the command runs: a quarter of it as cache (`shared_buffers`), three quarters
|
||||||
|
counted on for the planner, and room to sort and build indexes, all capped. A
|
||||||
|
member that also runs a control plane or an agent gets less: about 15% as
|
||||||
|
cache, so the gateway is not pushed into swap. A knowledge base's vector
|
||||||
|
index is searched well only from memory, so give a store machine more RAM and
|
||||||
|
run the command again. The new cache takes a PostgreSQL restart, which Patroni
|
||||||
|
does one member at a time, the leader last. The leader's restart costs a few
|
||||||
|
seconds without writes, and there is no failover.
|
||||||
|
|
||||||
Every step looks before it acts. Running it again finishes an install that
|
Every step looks before it acts. Running it again finishes an install that
|
||||||
stopped halfway and changes nothing on one that did not; it never overwrites
|
stopped halfway and changes nothing on one that did not; it never overwrites
|
||||||
`server.yaml`, the authority, or an existing account's password.
|
`server.yaml`, the authority, or an existing account's password.
|
||||||
@@ -321,7 +391,11 @@ by itself. Three things are worth knowing before you edit it:
|
|||||||
- `default_rpm`, `default_tpm` and `default_price` are what make limits and
|
- `default_rpm`, `default_tpm` and `default_price` are what make limits and
|
||||||
budgets mean anything. Without a price every request costs zero and a budget
|
budgets mean anything. Without a price every request costs zero and a budget
|
||||||
never binds; without the defaults, a project created without limits is
|
never binds; without the defaults, a project created without limits is
|
||||||
metered by nothing at all.
|
metered by nothing at all. A price may set `cached_per_1k` beside
|
||||||
|
`prompt_per_1k`: the prompt tokens the engine or provider read from its
|
||||||
|
cache (the answer's `cached_tokens`) are charged at it, the rest at the
|
||||||
|
prompt rate. A provider that bills cached reads at a tenth is
|
||||||
|
`{prompt_per_1k: 2500, cached_per_1k: 250, completion_per_1k: 10000}`.
|
||||||
- `control.enabled: true` is what makes this a control plane while its catalog
|
- `control.enabled: true` is what makes this a control plane while its catalog
|
||||||
is empty. Without it, a file with no models is refused as having nothing to
|
is empty. Without it, a file with no models is refused as having nothing to
|
||||||
serve.
|
serve.
|
||||||
@@ -1028,6 +1102,27 @@ qwen2.5-coder-3b-instruct yes 1/1 ready coder
|
|||||||
No restart, and it survives one: models added this way are stored, while
|
No restart, and it survives one: models added this way are stored, while
|
||||||
anything in `server.yaml` is declarative and returns on every start.
|
anything in `server.yaml` is declarative and returns on every start.
|
||||||
|
|
||||||
|
A profile may load weights of its own. An embedding model, for example, can
|
||||||
|
be served by vLLM from its repository on a card and by llama.cpp from a GGUF
|
||||||
|
on CPUs, as one model:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: qwen3-embed-4b
|
||||||
|
source: Qwen/Qwen3-Embedding-4B # what a profile loads unless it says
|
||||||
|
capabilities: { embeddings: true }
|
||||||
|
profiles:
|
||||||
|
- name: gpu
|
||||||
|
engine: vllm
|
||||||
|
gpu_class: 6000-24g
|
||||||
|
args: ["--runner", "pooling"]
|
||||||
|
- name: cpu
|
||||||
|
engine: llamacpp
|
||||||
|
source: hf:Qwen/Qwen3-Embedding-4B-GGUF/Qwen3-Embedding-4B-Q8_0.gguf
|
||||||
|
```
|
||||||
|
|
||||||
|
Updates and canaries move the model's `source`; a profile with its own keeps
|
||||||
|
it.
|
||||||
|
|
||||||
### Models that load when someone asks
|
### Models that load when someone asks
|
||||||
|
|
||||||
A cluster with more models than GPUs can hold every model in its catalog and
|
A cluster with more models than GPUs can hold every model in its catalog and
|
||||||
@@ -1256,10 +1351,185 @@ catalog, and a key rotated in the file is used within seconds. A model whose key
|
|||||||
file cannot be read is not offered. Everything else applies as to any model:
|
file cannot be read is not offered. Everything else applies as to any model:
|
||||||
prices and budgets, rate limits, usage records, and key allowlists.
|
prices and budgets, rate limits, usage records, and key allowlists.
|
||||||
|
|
||||||
|
A provider's limits are per key. With several keys, one answering 429 need not
|
||||||
|
hold up the model: give them all, each with an optional weight (its share
|
||||||
|
against the others'), in place of `api_key_file`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
external:
|
||||||
|
url: https://api.openai.com
|
||||||
|
model: gpt-4o-mini
|
||||||
|
api_key_files:
|
||||||
|
- {file: /etc/llmgw/secrets/openai-1.key, weight: 2}
|
||||||
|
- {file: /etc/llmgw/secrets/openai-2.key}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each key is an endpoint of its own, `external:cloud-mini#1`, `#2`. A key that
|
||||||
|
answers 429 is passed over while its breaker is open, and the request goes to
|
||||||
|
another. One that answers 401 (revoked, expired, mistyped) is passed over too,
|
||||||
|
and `external_key_refused` goes to the notification webhooks, naming the
|
||||||
|
endpoint and never the key. The usage records say which key answered by the
|
||||||
|
same name. A key whose file cannot be read is left out, and the others stay.
|
||||||
|
|
||||||
**Its prompts leave the cluster.** Decide who may send them: a key or project
|
**Its prompts leave the cluster.** Decide who may send them: a key or project
|
||||||
whose allowlist leaves the external model out cannot reach it, as a fallback or
|
whose allowlist leaves the external model out cannot reach it, as a fallback or
|
||||||
by name. The cluster page and `ctl models` mark it `external`.
|
by name. The cluster page and `ctl models` mark it `external`.
|
||||||
|
|
||||||
|
### Pictures from ComfyUI
|
||||||
|
|
||||||
|
Clients ask for pictures at OpenAI's `/v1/images/generations`. llmgw has them
|
||||||
|
made by ComfyUI: it fills a ComfyUI workflow with the request, queues it,
|
||||||
|
waits, and sends the pictures back as `b64_json`.
|
||||||
|
|
||||||
|
**On the cluster's own cards.** Use engine `comfyui`. The model is placed on a
|
||||||
|
GPU node like any other, started when asked for, and stopped when unused.
|
||||||
|
|
||||||
|
**The short way: a preset.** Some models come with llmgw, with their
|
||||||
|
workflow, custom nodes, packages and licence already found and tried:
|
||||||
|
|
||||||
|
```
|
||||||
|
llmgw ctl model presets
|
||||||
|
llmgw ctl model add flux-schnell # pictures, Apache 2.0, a card of 24 GB, Volta or newer
|
||||||
|
llmgw ctl model add triposr # 3D models from a picture, MIT, a card of 8 GB
|
||||||
|
```
|
||||||
|
|
||||||
|
A preset's profile says the card it needs, not a class, so it places on
|
||||||
|
whatever cards the site has. When the cluster's settings name no ComfyUI,
|
||||||
|
adding one sets `engines.comfyui` to the version this release installs, and
|
||||||
|
every node with an NVIDIA GPU installs it; a cluster that names a version,
|
||||||
|
or `""` for none, keeps it; the control planes then start again on that
|
||||||
|
setting, standbys first.
|
||||||
|
|
||||||
|
They also come by themselves. Once a node has a card that fits one, and the
|
||||||
|
catalog has nothing that makes what it makes, the leader adds it, on demand —
|
||||||
|
where the cluster already installs ComfyUI; it never sets `engines.comfyui`
|
||||||
|
by itself. A preset somebody removes stays out until it is added again.
|
||||||
|
`llmgw ctl model presets auto off` (the setting `presets.auto`) stops it.
|
||||||
|
|
||||||
|
The cluster page on the portal has a **Pictures and 3D models** card under
|
||||||
|
the models: each preset, the classes of card here it fits, whether it is in
|
||||||
|
the catalog and who added it, why the leader has not, an **Add** button (one
|
||||||
|
that would also install ComfyUI says so first) and the switch for adding
|
||||||
|
them by themselves. In the console, the models screen shows the same: **A**
|
||||||
|
adds one, **P** turns adding them by themselves on or off.
|
||||||
|
`llmgw ctl model presets` prints it. The rest of this section is for a model
|
||||||
|
of your own.
|
||||||
|
|
||||||
|
The node must offer the engine. Ask for it in the cluster's settings, and every
|
||||||
|
node with an NVIDIA GPU installs it:
|
||||||
|
|
||||||
|
```
|
||||||
|
llmgw ctl config set engines.comfyui 0.39.2
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent fetches ComfyUI's source from GitHub and checks it against the
|
||||||
|
digest llmgw holds for that version. GitHub publishes none for a tag's archive,
|
||||||
|
so a version llmgw does not know is refused. The agent then builds a Python
|
||||||
|
environment beside the source, under `/opt/llmgw/engines/comfyui/`. Its
|
||||||
|
dependencies are resolved as PyPI stood the day after the release. torch comes
|
||||||
|
from PyPI (CUDA 13), from PyTorch's CUDA 12.9 build where the driver is older
|
||||||
|
than 580, or from its CUDA 12.6 build on cards older than Turing (a V100):
|
||||||
|
torch's CUDA 12.8 and newer builds have no kernels for them. The install is
|
||||||
|
checked on the node's GPU, and takes about 10 GB.
|
||||||
|
`engine_overrides` can keep it off nodes that should not have it.
|
||||||
|
|
||||||
|
Alternatively, install ComfyUI by hand. You need a directory with `main.py` and
|
||||||
|
its environment in `.venv`, readable by the user engines run as
|
||||||
|
(`llmgw-engine`). Name it in that node's `agent.yaml`; a node with one named
|
||||||
|
there is not given the installed one:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
engines:
|
||||||
|
comfyui:
|
||||||
|
dir: /opt/comfyui # python: defaults to /opt/comfyui/.venv/bin/python
|
||||||
|
```
|
||||||
|
|
||||||
|
Then add the model:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: flux-schnell
|
||||||
|
source: hf:Comfy-Org/flux1-schnell/flux1-schnell-fp8.safetensors
|
||||||
|
comfyui:
|
||||||
|
workflow: flux-schnell # or flux-dev, or a JSON file
|
||||||
|
scaling: {min: 0, max: 1, idle_timeout: 15m}
|
||||||
|
profiles:
|
||||||
|
- name: gpu
|
||||||
|
engine: comfyui
|
||||||
|
gpu_class: 6000-24g
|
||||||
|
tp: 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The source is fetched as for any model. It can be one file, such as the
|
||||||
|
single-file checkpoints the built-in workflows load. It can also be a mirror
|
||||||
|
set (a URL ending in `/`) for a model in several files, such as a diffusion
|
||||||
|
model, its text encoders and its VAE. ComfyUI sees every file of a set by its
|
||||||
|
own name, so a workflow of your own can load each one. A set must name the
|
||||||
|
file that `{{checkpoint}}` stands for in `comfyui.checkpoint`. A single file
|
||||||
|
is named by its own file name.
|
||||||
|
|
||||||
|
ComfyUI asks nobody for a key. The agent therefore starts it behind a gate
|
||||||
|
(`llmgw comfyui-gate`). ComfyUI listens on the node's loopback only. The gate
|
||||||
|
listens on the replica's port, demands the replica's key, and lets through only
|
||||||
|
what the router asks: queueing, waiting, fetching a picture and cancelling.
|
||||||
|
Uploads and node installs are refused. Each replica has its own directory,
|
||||||
|
`~llmgw-engine/comfyui/REPLICA`, and ComfyUI's output can be read with `llmgw
|
||||||
|
ctl logs NODE --source SHARD`. A profile's `args` go to `main.py`.
|
||||||
|
`--disable-smart-memory` frees the card between pictures, at the cost of loading
|
||||||
|
the model again for each one.
|
||||||
|
|
||||||
|
**On a ComfyUI server outside the cluster.** Make it an external model with
|
||||||
|
`api: comfyui`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: flux-schnell
|
||||||
|
source: external
|
||||||
|
external:
|
||||||
|
url: http://10.0.0.147:8188 # ComfyUI, started with --listen
|
||||||
|
model: flux1-schnell-fp8.safetensors # the checkpoint, as ComfyUI lists it
|
||||||
|
api: comfyui
|
||||||
|
workflow: flux-schnell # or flux-dev, or a JSON file
|
||||||
|
```
|
||||||
|
|
||||||
|
Like any external model, its requests count as leaving the cluster.
|
||||||
|
|
||||||
|
`flux-schnell` (the default) and `flux-dev` are built in. They suit the
|
||||||
|
single-file FLUX checkpoints that ComfyUI loads with `CheckpointLoaderSimple`.
|
||||||
|
For any other model, export the workflow from ComfyUI with **Save (API)** and
|
||||||
|
name the file. In the file, put placeholders where the request's values go:
|
||||||
|
|
||||||
|
- `{{prompt}}` and `{{negative_prompt}}` are the prompts.
|
||||||
|
- `{{width}}` and `{{height}}` are the size.
|
||||||
|
- `{{n}}` is the batch size.
|
||||||
|
- `{{seed}}` is the seed.
|
||||||
|
- `{{steps|4}}` and `{{guidance|3.5}}` take the value after the `|` when the
|
||||||
|
request leaves them out.
|
||||||
|
- `{{checkpoint}}` is the `model` above.
|
||||||
|
|
||||||
|
A request is OpenAI's: `prompt`, `n` (at most 4), `size` (each side a multiple
|
||||||
|
of 16, from 256 to 2048) and `response_format: b64_json`. Beyond those, it may
|
||||||
|
send `seed`, `steps`, `negative_prompt` and `guidance`. The seed used comes
|
||||||
|
back in `x-llmgw-seed`. `url` is not offered, because pictures are kept
|
||||||
|
nowhere. ComfyUI also writes the workflow into each PNG; llmgw strips it
|
||||||
|
before the picture is sent.
|
||||||
|
|
||||||
|
Requests for pictures pass the same checks as any other request: keys,
|
||||||
|
allowlists, limits, admission, and the guardrails on the prompt. A client that
|
||||||
|
leaves takes its workflow off the ComfyUI queue.
|
||||||
|
|
||||||
|
Each request's usage row records how many pictures it made (`llmgw ctl request
|
||||||
|
ID` shows them). A picture's price is set with `per_image`, in micro-units like
|
||||||
|
the other prices, and is charged on top of the prompt's tokens:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
pricing:
|
||||||
|
flux-schnell: {prompt_per_1k: 0, per_image: 20000}
|
||||||
|
```
|
||||||
|
|
||||||
|
An external ComfyUI on a card that llmgw also places models on should be
|
||||||
|
started with `--disable-smart-memory`. Without it, ComfyUI keeps the model on
|
||||||
|
the card after each picture, and llmgw sees that memory as somebody else's.
|
||||||
|
|
||||||
### Federating clusters
|
### Federating clusters
|
||||||
|
|
||||||
Another llmgw cluster — another site, another team's GPUs — can be a provider
|
Another llmgw cluster — another site, another team's GPUs — can be a provider
|
||||||
@@ -1540,6 +1810,7 @@ and accounting exactly as an API call does.
|
|||||||
| `POST /v1/audio/transcriptions`, `/v1/audio/translations` | the multipart form passed through as it came, to an engine that serves speech to text: vLLM with Whisper |
|
| `POST /v1/audio/transcriptions`, `/v1/audio/translations` | the multipart form passed through as it came, to an engine that serves speech to text: vLLM with Whisper |
|
||||||
| `POST /v1/moderations` | answered by the guardrails' judge model (`role: guard`): `flagged`, and the judge's own categories |
|
| `POST /v1/moderations` | answered by the guardrails' judge model (`role: guard`): `flagged`, and the judge's own categories |
|
||||||
| `/v1/files`, `/v1/batches` | batches; see [Batches](#batches) |
|
| `/v1/files`, `/v1/batches` | batches; see [Batches](#batches) |
|
||||||
|
| `/v1/3d/generations` | 3D models, as jobs asked after; see [Jobs](#jobs-3d-models) |
|
||||||
| `GET /v1/models` | the models this key may use |
|
| `GET /v1/models` | the models this key may use |
|
||||||
|
|
||||||
## Watching it
|
## Watching it
|
||||||
@@ -1640,6 +1911,23 @@ same event about the same thing is sent at most once an hour, so a budget that
|
|||||||
refuses a thousand requests is one message. `llmgw ctl notify test` sends a
|
refuses a thousand requests is one message. `llmgw ctl notify test` sends a
|
||||||
test event to each webhook and says how each answered.
|
test event to each webhook and says how each answered.
|
||||||
|
|
||||||
|
Setup asks for them: **Notification webhooks** on its first page, or
|
||||||
|
`--webhook` to `llmgw server init`, as often as there are webhooks:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo llmgw server init --admin ops@example.com \
|
||||||
|
--webhook https://hooks.slack.com/services/… \
|
||||||
|
--webhook json:https://ops.example.com/llmgw
|
||||||
|
```
|
||||||
|
|
||||||
|
An address of Slack's, or one with `/hooks/` in it (Mattermost, Rocket.Chat)
|
||||||
|
or ending in `/slack`, is sent Slack's `{"text": …}`; any other the event as
|
||||||
|
JSON; `slack:` or `json:` in front says which. They are added to the
|
||||||
|
cluster's settings beside any already there — run again, nothing is added
|
||||||
|
twice — and once the control plane runs on them, each is sent a test, whose
|
||||||
|
answer setup prints. Events to choose and a signing secret are set in the
|
||||||
|
settings afterwards (`llmgw ctl config edit`).
|
||||||
|
|
||||||
### Tracing
|
### Tracing
|
||||||
|
|
||||||
Metrics say that a model took four minutes to start; a trace says where they
|
Metrics say that a model took four minutes to start; a trace says where they
|
||||||
@@ -1673,6 +1961,18 @@ So "why did qwen take four minutes to start" is one trace: find `replica start`
|
|||||||
with `llmgw.model` = qwen, and the node's spans beneath it say whether the
|
with `llmgw.model` = qwen, and the node's spans beneath it say whether the
|
||||||
time went to the download, the launch or the engine loading.
|
time went to the download, the launch or the engine loading.
|
||||||
|
|
||||||
|
An inference request and each `upstream` attempt also carry the
|
||||||
|
[OpenTelemetry GenAI conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/),
|
||||||
|
so a tool that reads them — Grafana, Jaeger, Langfuse, Arize Phoenix — shows
|
||||||
|
llmgw's calls as model calls without a dashboard made for llmgw:
|
||||||
|
`gen_ai.operation.name` (`chat`, `text_completion`, `embeddings`),
|
||||||
|
`gen_ai.request.model`, `gen_ai.request.max_tokens`, `temperature` and `top_p`
|
||||||
|
when the client set them, `gen_ai.conversation.id` (the session),
|
||||||
|
`gen_ai.response.model`, and `gen_ai.usage.input_tokens`, `output_tokens` and
|
||||||
|
`cache_read.input_tokens`. On an `upstream` span `gen_ai.provider.name` is the
|
||||||
|
engine that answered (`vllm`, `llamacpp`, `external`, …). What was asked and
|
||||||
|
what was answered are never in a span.
|
||||||
|
|
||||||
A node follows the control plane's decision: a start the control plane kept is
|
A node follows the control plane's decision: a start the control plane kept is
|
||||||
traced on the node whatever the agent's own `sample_ratio`, and one it sampled
|
traced on the node whatever the agent's own `sample_ratio`, and one it sampled
|
||||||
out is not; the agent's ratio decides only for what arrives with no trace, as
|
out is not; the agent's ratio decides only for what arrives with no trace, as
|
||||||
@@ -1736,9 +2036,11 @@ the file again.
|
|||||||
|
|
||||||
On a machine that also runs the control plane, PostgreSQL's defaults assume it
|
On a machine that also runs the control plane, PostgreSQL's defaults assume it
|
||||||
owns the box. A gateway swapped out to give the database cache is a worse
|
owns the box. A gateway swapped out to give the database cache is a worse
|
||||||
outcome than a smaller cache, so cap it: `shared_buffers = 96MB` and
|
outcome than a smaller cache, so size it to the machine. `server init` does this
|
||||||
`max_connections = 40` are enough for a store whose working set is measured in
|
for a PostgreSQL it installs: `shared_buffers` about 15% of memory (at least
|
||||||
megabytes.
|
96MB), `effective_cache_size` half, `work_mem` and `maintenance_work_mem`
|
||||||
|
scaled to it, and `max_connections = 40`. A store with a large knowledge base
|
||||||
|
wants a machine of its own, where its vector index fits in memory.
|
||||||
|
|
||||||
## Tagging requests
|
## Tagging requests
|
||||||
|
|
||||||
@@ -1856,8 +2158,46 @@ router:
|
|||||||
max_entry_bytes: 2097152 # 2 MB
|
max_entry_bytes: 2097152 # 2 MB
|
||||||
```
|
```
|
||||||
|
|
||||||
`llmgw_cache_requests_total{model,result}` counts hits, misses and
|
### Similar questions
|
||||||
bypasses, `llmgw_cache_bytes` and `llmgw_cache_entries` what is held. The
|
|
||||||
|
A help desk asked "how do I reset my password" in forty wordings asks the
|
||||||
|
model forty times under the exact cache. `--similar` also gives a chat
|
||||||
|
question the answer to one like it:
|
||||||
|
|
||||||
|
```
|
||||||
|
llmgw keys cache --project Acme/support/desk --ttl 24h --similar 0.95
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
router:
|
||||||
|
cache:
|
||||||
|
embedding_model: bge-m3 # an embedding model in the catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
- **What is compared:** the last user message, embedded by
|
||||||
|
`router.cache.embedding_model`, against the questions already answered
|
||||||
|
in the same conversation — the same system prompt, the same earlier turns,
|
||||||
|
tools, `response_format` and sampling settings, the same model and
|
||||||
|
project. A like question in another conversation, or after a tool's
|
||||||
|
result, is not taken for this one. The exact cache is asked first, and an
|
||||||
|
exact hit embeds nothing.
|
||||||
|
- **How alike:** the cosine similarity of the two embeddings, at least the
|
||||||
|
project's `--similar`. 0.95 is strict; below 0.9 different questions start
|
||||||
|
to meet. A question that differs only in a number or a name ("2+2" and
|
||||||
|
"2+3") can be closer than that, so a project whose questions turn on
|
||||||
|
figures should not use it. A question over 2,000 characters is reused only
|
||||||
|
when it is the same.
|
||||||
|
- **Said:** `x-llmgw-cache: similar`, with `x-llmgw-cache-similarity`
|
||||||
|
and `Age`. Recorded and charged as a hit. The same rules as a hit decide
|
||||||
|
which answers are reused and kept; guardrails judge the request before.
|
||||||
|
- **The embedding** is asked of the model by the gateway on its own account,
|
||||||
|
as a judge is, within 2 seconds; every miss waits for it. Without the
|
||||||
|
model, or when it does not answer, only the same request is reused. The
|
||||||
|
vectors are kept in memory with their answers and counted in
|
||||||
|
`router.cache.max_bytes`.
|
||||||
|
|
||||||
|
`llmgw_cache_requests_total{model,result}` counts hits, similar hits, misses
|
||||||
|
and bypasses, `llmgw_cache_bytes` and `llmgw_cache_entries` what is held. The
|
||||||
console has it as **Cache answers…** on a project.
|
console has it as **Cache answers…** on a project.
|
||||||
|
|
||||||
## Batches
|
## Batches
|
||||||
@@ -1896,6 +2236,142 @@ Each line of the file is `{"custom_id": …, "method": "POST", "url":
|
|||||||
or past their retention — a project under a guardrails policy with
|
or past their retention — a project under a guardrails policy with
|
||||||
`no_store` is refused it.
|
`no_store` is refused it.
|
||||||
|
|
||||||
|
## Jobs: 3D models
|
||||||
|
|
||||||
|
A 3D model from a picture takes minutes on a card that first has to be
|
||||||
|
emptied of whatever else was on it. No client should hold a connection for
|
||||||
|
that, so it is a job: queued, answered with an id, asked after, its result
|
||||||
|
downloaded.
|
||||||
|
|
||||||
|
```
|
||||||
|
curl http://gateway:8080/v1/3d/generations -H "Authorization: Bearer $LLMGW_API_KEY" \
|
||||||
|
-d '{"model": "trellis2", "image": "<base64>"}'
|
||||||
|
# → 202 {"id": "job_…", "status": "queued", …}
|
||||||
|
curl http://gateway:8080/v1/3d/generations/job_… -H "Authorization: Bearer $LLMGW_API_KEY"
|
||||||
|
# → {"status": "completed", "output_file_id": "file-…", "content_type": "model/gltf-binary", "usage": {…}}
|
||||||
|
curl http://gateway:8080/v1/files/file-…/content -H "Authorization: Bearer $LLMGW_API_KEY" -o model.glb
|
||||||
|
curl -X POST http://gateway:8080/v1/3d/generations/job_…/cancel -H "Authorization: Bearer $LLMGW_API_KEY"
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Status:** `queued`, `running`, `completed`, `failed` (with `error`),
|
||||||
|
`cancelling`, `cancelled`, `expired` (not finished within 24 h).
|
||||||
|
`progress` carries what the engine said of the work so far, once it says
|
||||||
|
anything; `GET /v1/3d/generations` lists the caller's jobs. While the
|
||||||
|
model waits for another on-demand model to give up the card,
|
||||||
|
`progress` is `{"state":"waiting_gpu","held_by":"flux-schnell","eta_seconds":N}`:
|
||||||
|
N is when the holder is stopped (idle and old enough to evict, or at its
|
||||||
|
idle timeout) plus how long the model's last start took, and is left out
|
||||||
|
when that is not known. A request made directly gets the same as a 503
|
||||||
|
with `X-Llmgw-Waiting-For`, `X-Llmgw-Eta-Seconds` and `Retry-After`.
|
||||||
|
- **Refused at the door:** no key, a model that is not there or not in the
|
||||||
|
key's allowlist, a request the model cannot serve. Refused when served,
|
||||||
|
and said as the job's error: a spent budget, the guardrails, a model
|
||||||
|
that will not come up.
|
||||||
|
- **How it is served:** the request is kept as it was sent and replayed by
|
||||||
|
whichever gateway holds the job's lease, through the same path a
|
||||||
|
client's request takes — limits, admission in the key's band, guardrails,
|
||||||
|
accounting — by the leading control plane, since a standby has nothing
|
||||||
|
to send it to. A model said to fit nowhere is asked for again for five
|
||||||
|
minutes before that is the job's error: an agent re-registering says so
|
||||||
|
for a few seconds. It is one usage record under the job's id, tagged
|
||||||
|
`job=<id>`, charged like any request; `GET` shows the usage once it is
|
||||||
|
done. A model still loading, a rate limit or a full queue is waited out
|
||||||
|
for as long as the job has: nobody is holding a connection, which is the
|
||||||
|
point.
|
||||||
|
- **Surviving a restart:** a gateway that stops hands its jobs to another
|
||||||
|
within two minutes; a job it was running is run again from the start.
|
||||||
|
- **What it keeps:** the request as sent, and the result as a file owned
|
||||||
|
by the key's project, kept `router.batch.retain_days` (30) like a batch's
|
||||||
|
files; the job's record goes with it. A project under a `no_store`
|
||||||
|
policy is refused jobs, as it is batches.
|
||||||
|
|
||||||
|
A request may also send `seed` and `resolution` (the model's own grid, a
|
||||||
|
multiple of 32 from 64 to 2048). The picture should show one object on a
|
||||||
|
plain background: the model makes a mesh of what it sees, background
|
||||||
|
included.
|
||||||
|
|
||||||
|
### A model that makes them
|
||||||
|
|
||||||
|
A 3D model is made by a ComfyUI model whose `comfyui.output` is `meshes`
|
||||||
|
(see [Pictures from ComfyUI](#pictures-from-comfyui) for engine `comfyui`).
|
||||||
|
Its workflow is a JSON file in ComfyUI's API format: `{{image}}` is the
|
||||||
|
uploaded picture, which a `LoadImage` node reads; `{{seed}}`,
|
||||||
|
`{{resolution|256}}` and `{{checkpoint}}` are as for pictures. The node that
|
||||||
|
saves the mesh may save a GLB or an OBJ; an OBJ leaves the gateway as a GLB,
|
||||||
|
vertex colours kept.
|
||||||
|
|
||||||
|
TripoSR (MIT), on any card of 8 GB, comes with llmgw: `llmgw ctl model add
|
||||||
|
triposr`. What that adds is this entry, and its workflow is built in:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: triposr
|
||||||
|
source: hf:stabilityai/TripoSR/model.ckpt
|
||||||
|
comfyui:
|
||||||
|
workflow: triposr # built in; a model of your own names a JSON file on every control plane
|
||||||
|
output: meshes
|
||||||
|
nodes:
|
||||||
|
- repo: https://github.com/flowtyone/ComfyUI-Flowty-TripoSR
|
||||||
|
commit: a0c94ac60a7cc062604f61aeeea6d0d493521de3
|
||||||
|
requirements: [omegaconf, trimesh, scikit-image, imageio,
|
||||||
|
"transformers>=4.50.3,<5", "huggingface-hub<1", tokenizers,
|
||||||
|
rembg, onnxruntime]
|
||||||
|
license:
|
||||||
|
id: MIT
|
||||||
|
name: MIT
|
||||||
|
commercial: true
|
||||||
|
url: https://huggingface.co/stabilityai/TripoSR/blob/main/LICENSE
|
||||||
|
notice: "Made with TripoSR (Stability AI and Tripo AI, MIT). ..."
|
||||||
|
scaling: {min: 0, max: 1, idle_timeout: 10m}
|
||||||
|
profiles:
|
||||||
|
- {name: gpu, engine: comfyui, tp: 1, gpu: {min_compute_capability: 7.0, min_memory_gb: 8}}
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"1": {"class_type": "LoadImage", "inputs": {"image": "{{image}}"}},
|
||||||
|
"5": {"class_type": "LLMGWPrepareForMesh", "inputs": {"image": ["1", 0], "mask": ["1", 1],
|
||||||
|
"foreground_ratio": 0.85, "background": 0.5, "remove_background": "auto", "rembg_model": "u2net"}},
|
||||||
|
"2": {"class_type": "TripoSRModelLoader", "inputs": {"model": "{{checkpoint}}", "chunk_size": 8192}},
|
||||||
|
"3": {"class_type": "TripoSRSampler", "inputs": {"model": ["2", 0], "reference_image": ["5", 0],
|
||||||
|
"geometry_resolution": "{{resolution|256}}", "threshold": 25.0}},
|
||||||
|
"4": {"class_type": "TripoSRViewer", "inputs": {"mesh": ["3", 0]}}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`LLMGWPrepareForMesh` comes with the gate: it cuts the object out — by the
|
||||||
|
picture's alpha, or by rembg where it has none — and centres it on a plain
|
||||||
|
background, without which the background becomes geometry.
|
||||||
|
|
||||||
|
- **Custom nodes** are named by https repository and full commit (a branch
|
||||||
|
is refused: it moves). The gate puts each in the replica's own
|
||||||
|
`custom_nodes` and installs its `requirements` beside the replica, never
|
||||||
|
into ComfyUI's environment. A package named there is installed at the
|
||||||
|
range named; every other one at the version ComfyUI already runs. A
|
||||||
|
package named is first on ComfyUI's path, so its range must suit ComfyUI
|
||||||
|
as well. TripoSR's checkpoint loads with transformers 4, which ComfyUI's
|
||||||
|
`>=4.50.3` allows.
|
||||||
|
- **Progress:** the gate listens to ComfyUI's websocket and the router
|
||||||
|
writes what it hears into the job: `{"state": "queued", "ahead": 2}`,
|
||||||
|
then `{"state": "running", "node_class": "KSampler", "step": 12,
|
||||||
|
"steps": 30, "percent": 40}` for a node that counts its steps, and
|
||||||
|
`done` at the end.
|
||||||
|
- **Licence:** a model's `license` is shown in `/v1/models`, in the job and
|
||||||
|
on the portal, and written into the GLB's `asset.extras.license`, since
|
||||||
|
the file travels without the API. Choose the model for its licence before
|
||||||
|
its quality when what it makes is shipped. TRELLIS.2 needs Meta's gated
|
||||||
|
DINOv3 and, as published, a background remover under a non-commercial
|
||||||
|
licence; Hunyuan3D's licence excludes the EU, the UK and South Korea.
|
||||||
|
- **Price:** `per_mesh` in `auth.pricing`, beside `per_image`; each usage
|
||||||
|
row says how many meshes it made (`meshes`).
|
||||||
|
- **Cards:** the strongest models today (TRELLIS.2, Direct3D-S2) need
|
||||||
|
flash-attention, which runs on Ampere and newer. A Turing card such as
|
||||||
|
the RTX 6000 runs TripoSR.
|
||||||
|
|
||||||
|
People make them at `/3d` on the portal: they choose a picture and watch
|
||||||
|
the job, and the mesh is shown in a viewer (model-viewer, served from the
|
||||||
|
gateway) with a download link and the licence. `llmgw ctl 3d list`, `get`
|
||||||
|
and `cancel` show and stop every owner's jobs.
|
||||||
|
|
||||||
## Conversations across models
|
## Conversations across models
|
||||||
|
|
||||||
Off everywhere until a project asks. llmgw otherwise stores no message content
|
Off everywhere until a project asks. llmgw otherwise stores no message content
|
||||||
@@ -1977,6 +2453,33 @@ Without authentication, `threads.store` and `threads.retain_hours` in
|
|||||||
`server.yaml` keep both conversations and notes in one shared pool. That is for
|
`server.yaml` keep both conversations and notes in one shared pool. That is for
|
||||||
one trusted machine only; see [development.md](development.md).
|
one trusted machine only; see [development.md](development.md).
|
||||||
|
|
||||||
|
## Three machines, each a control plane
|
||||||
|
|
||||||
|
The screens' three machines are high availability for the gateway as well as
|
||||||
|
the store: the two database machines are made control planes too, standing
|
||||||
|
by while the first leads — what `server add`, below, does for one machine.
|
||||||
|
By command, `--standbys` with `--patroni`, and `--vip` for the floating
|
||||||
|
address:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo --preserve-env=SSH_AUTH_SOCK llmgw server init --admin you@example.com \
|
||||||
|
--patroni ubuntu@10.0.0.6 --patroni ubuntu@10.0.0.7 \
|
||||||
|
--standbys --vip 10.0.0.160/24 --no-node --no-model
|
||||||
|
```
|
||||||
|
|
||||||
|
| Machine | Runs |
|
||||||
|
|---|---|
|
||||||
|
| the first | control plane, leading at first; model mirror; etcd |
|
||||||
|
| the other two | control plane, standing by; PostgreSQL under Patroni; etcd |
|
||||||
|
|
||||||
|
etcd votes on all three and PostgreSQL is on two, so any one machine can be
|
||||||
|
lost: a standby leads within seconds, Patroni promotes the standby database
|
||||||
|
if it was the primary, and the floating address follows whichever leads. The
|
||||||
|
mirror's management API answers the three control planes' addresses, not
|
||||||
|
only its own machine's, so a standby that comes to lead can still fetch and
|
||||||
|
add models. The mirror itself stays on the first machine: while it is down,
|
||||||
|
models the nodes already have keep serving, and new ones wait for it.
|
||||||
|
|
||||||
## Two control planes
|
## Two control planes
|
||||||
|
|
||||||
Once the store is replicated (`server init --patroni`, above), one command on
|
Once the store is replicated (`server init --patroni`, above), one command on
|
||||||
@@ -1993,8 +2496,12 @@ and lets it into the store; it starts there as a standby. Every node is told
|
|||||||
of it within half a minute and fails over to it without being touched.
|
of it within half a minute and fails over to it without being touched.
|
||||||
`--vip` keeps a floating address on whichever control plane leads, with
|
`--vip` keeps a floating address on whichever control plane leads, with
|
||||||
keepalived on both: that is the address to give clients, or a proxy in front.
|
keepalived on both: that is the address to give clients, or a proxy in front.
|
||||||
Without it, put a load balancer in front that checks `/ready` — not
|
Without it, put a proxy in front that sends to whichever answers `/leader`
|
||||||
`/health`, which is 200 on a standby that would refuse every request.
|
with 200 — not `/health`, which is 200 on a standby too, and not `/ready`,
|
||||||
|
which is 503 on a leader with no model yet and would take the portal away
|
||||||
|
with it. The end of [deploy/proxy/Caddyfile](../deploy/proxy/Caddyfile) has
|
||||||
|
Caddy doing that for three control planes, and
|
||||||
|
[deploy/ha/](../deploy/ha/README.md#check-leader-not-health) why.
|
||||||
|
|
||||||
- **What a failover costs.** Measured on the reference cluster, stopping the
|
- **What a failover costs.** Measured on the reference cluster, stopping the
|
||||||
leader: the standby led within five seconds, every node had moved to it
|
leader: the standby led within five seconds, every node had moved to it
|
||||||
@@ -2055,6 +2562,79 @@ the same files and keys as `locales/en/`, and its `_meta.*` entries saying how
|
|||||||
it writes numbers and dates. The tests refuse a catalogue that lacks a key a
|
it writes numbers and dates. The tests refuse a catalogue that lacks a key a
|
||||||
page uses.
|
page uses.
|
||||||
|
|
||||||
|
## The company's MCP servers
|
||||||
|
|
||||||
|
llmgw's `/mcp` can serve the company's own MCP servers beside its own tools.
|
||||||
|
Each server, a ticket system, a file server or a database, is registered once.
|
||||||
|
Its credentials stay on the control planes, and people's agents are given only
|
||||||
|
`/mcp` and their llmgw key.
|
||||||
|
|
||||||
|
```
|
||||||
|
llmgw ctl mcp add jira --url https://jira.internal/mcp \
|
||||||
|
--description "Acme Jira" --secret-file Authorization=/root/jira-token
|
||||||
|
llmgw ctl mcp test jira # its tools, asked with its credentials
|
||||||
|
llmgw keys limits --scope team --id Acme/support --tools 'jira.*'
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Credentials:**
|
||||||
|
- `--secret-file NAME=FILE` reads a header's value once and keeps it in
|
||||||
|
the store, sealed by the master key, so no file has to be on each
|
||||||
|
control plane.
|
||||||
|
- `--header-ref NAME=REF` reads it at each call from a file, `env:`,
|
||||||
|
`vault:` or `store:` instead.
|
||||||
|
- `--header NAME=VALUE` is for what is not secret.
|
||||||
|
- **Who may call what:** tools are named `server.tool` and granted as
|
||||||
|
patterns (`jira.*`, `db.query`) at any level of the tenancy tree, as
|
||||||
|
models are.
|
||||||
|
- A level that names any narrows what is above it.
|
||||||
|
- Unlike models, **none granted anywhere is none**: a tool reaches into a
|
||||||
|
company system, so it is granted, never assumed.
|
||||||
|
- The console's limits form has the same field.
|
||||||
|
- **The server is told who calls:** `X-Llmgw-Key`, and `X-Llmgw-User` and
|
||||||
|
`X-Llmgw-Project` where there are some. It is called with llmgw's
|
||||||
|
credentials, never the caller's key.
|
||||||
|
- **Bounds:** a call has 60 seconds (`--timeout`) and an answer 4 MB. A
|
||||||
|
server's progress is passed on to a client that takes a stream. `--rate N`
|
||||||
|
allows each caller N calls a minute to a server's tools.
|
||||||
|
- **Guardrails, both ways:** the caller's policies read a tool's answer as
|
||||||
|
the prompt it becomes.
|
||||||
|
- An instruction planted in a ticket is caught as a pasted one would be
|
||||||
|
(`injection: block`).
|
||||||
|
- Personal data is redacted or refused as the policy says. A refused
|
||||||
|
answer does not reach the agent.
|
||||||
|
- The arguments are read before they leave, so a secret pasted into one
|
||||||
|
does not reach the company system.
|
||||||
|
- **A server that keeps failing:** five calls failing in a row take a server
|
||||||
|
out for a minute. Its tools leave the list, and a call is answered at once.
|
||||||
|
- **Record:** every call is in `llmgw ctl audit`, with its arguments' size
|
||||||
|
and a digest, never their text. `llmgw_mcp_tool_calls_total` and
|
||||||
|
`llmgw_mcp_tool_call_seconds` count them by server, tool and outcome.
|
||||||
|
- **Changes:** a server added, changed or removed on one control plane is
|
||||||
|
served by every one within half a minute; its tool list is read again
|
||||||
|
every five minutes. `--disabled` keeps a server but serves none of its
|
||||||
|
tools.
|
||||||
|
|
||||||
|
**This cluster's own, read only.** `llmgw ctl mcp add llmgw --url
|
||||||
|
llmgw:admin` registers the cluster itself as a server: tools that say what
|
||||||
|
`llmgw ctl` does, and change nothing. It is answered in the control plane's
|
||||||
|
own process, with no address and no token to give.
|
||||||
|
|
||||||
|
| Tool | Says |
|
||||||
|
|---|---|
|
||||||
|
| `llmgw.cluster_status` | control planes, store, nodes, models against what they asked for, GPUs, what is unhealthy |
|
||||||
|
| `llmgw.list_nodes` | each node's state, GPUs, load, fabrics, agent version and engines |
|
||||||
|
| `llmgw.list_models` | each model's replicas ready against wanted, profiles, why one is parked |
|
||||||
|
| `llmgw.list_replicas` | each replica's shards: node, GPUs, state (`model` for one model's) |
|
||||||
|
| `llmgw.explain_placement` | why a model is not placed, node by node |
|
||||||
|
| `llmgw.node_logs` | a node's last agent log lines, or an engine's (`source`) |
|
||||||
|
|
||||||
|
Grant it as any other: `llmgw keys limits --scope key --id KEY-ID --tools
|
||||||
|
'llmgw.*'`. It is the same as an administrator's view, so grant it to the
|
||||||
|
people who would have `ctl`.
|
||||||
|
|
||||||
|
Servers over HTTP only for now (the streamable HTTP transport). A server
|
||||||
|
that runs as a local process (stdio) is planned.
|
||||||
|
|
||||||
## Single sign-on
|
## Single sign-on
|
||||||
|
|
||||||
People can sign in through your identity provider — Keycloak, Entra ID,
|
People can sign in through your identity provider — Keycloak, Entra ID,
|
||||||
@@ -2062,8 +2642,10 @@ Google, Okta, Authentik, anything that speaks OpenID Connect.
|
|||||||
|
|
||||||
1. Register a client there with the redirect address
|
1. Register a client there with the redirect address
|
||||||
`https://<gateway>/login/oidc/callback`.
|
`https://<gateway>/login/oidc/callback`.
|
||||||
2. Put its secret in a file only root reads.
|
2. Put its secret in a file the llmgw user reads, on every control plane.
|
||||||
3. Add to `server.yaml`:
|
3. Add this to the cluster's settings with `llmgw ctl config edit`. On a
|
||||||
|
cluster with a store, `auth` is the cluster's: the same block in
|
||||||
|
`server.yaml` is not read, and the log says so.
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
auth:
|
auth:
|
||||||
@@ -2112,6 +2694,99 @@ and point it at `https://<gateway>/scim/v2` with that token as the bearer.
|
|||||||
join and leave them. Membership in a group is removed when someone leaves
|
join and leave them. Membership in a group is removed when someone leaves
|
||||||
it.
|
it.
|
||||||
|
|
||||||
|
### Entra ID
|
||||||
|
|
||||||
|
The console sets this up: `sudo llmgw`, People → Sign-in, **E**. What
|
||||||
|
follows is what it writes, and what to do in Entra.
|
||||||
|
|
||||||
|
Entra ID is an OpenID Connect provider like the others, with two things to
|
||||||
|
set in the app registration:
|
||||||
|
|
||||||
|
1. **Redirect URI:** under Authentication, add a Web platform with
|
||||||
|
`https://<gateway>/login/oidc/callback`. The issuer is
|
||||||
|
`https://login.microsoftonline.com/<tenant-id>/v2.0`.
|
||||||
|
2. **Groups in the token:** under Token configuration, add a groups claim.
|
||||||
|
Choose **Groups assigned to the application**, and assign the groups you
|
||||||
|
map in `roles` and `memberships` to the enterprise application. Entra
|
||||||
|
sends groups by object id unless you choose to emit names
|
||||||
|
(`sAMAccountName` for groups synced from AD), so write them the same way
|
||||||
|
in the mapping.
|
||||||
|
|
||||||
|
Entra leaves the groups out of the token for someone in more than 200 of
|
||||||
|
them and sends a pointer to Graph instead. llmgw then keeps that person's
|
||||||
|
role and memberships as they were, rather than reading it as no groups, and
|
||||||
|
logs an error saying so. "Groups assigned to the application" avoids this.
|
||||||
|
So does SCIM below, which Entra also speaks: in the enterprise application
|
||||||
|
under Provisioning, set the tenant URL to `https://<gateway>/scim/v2` and
|
||||||
|
the secret token to the contents of `auth.scim.token_file`.
|
||||||
|
|
||||||
|
## Active Directory
|
||||||
|
|
||||||
|
With an on-premises Active Directory, people sign in on the gateway's own
|
||||||
|
form with their AD username (`ada`, `CORP\ada`, `ada@corp.example`) and
|
||||||
|
password. The gateway checks them with the domain controllers over LDAPS.
|
||||||
|
|
||||||
|
The console sets this up and tries it first: `sudo llmgw`, People →
|
||||||
|
Sign-in, **D**. It keeps the service account's password in the store
|
||||||
|
(`bind_password_file: store:auth/ldap-bind`) and writes the CA into the
|
||||||
|
settings (`ca:`), so nothing has to be copied to each control plane.
|
||||||
|
By hand it is this block in the cluster's settings (`llmgw ctl config
|
||||||
|
edit`), where files it names must then be on every control plane:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
ldap:
|
||||||
|
urls: [ldaps://dc1.corp.example, ldaps://dc2.corp.example] # tried in order
|
||||||
|
ca_file: /etc/llmgw/corp-ca.pem # the domain's CA, when not in the system's
|
||||||
|
bind_dn: CN=svc-llmgw,OU=Service,DC=corp,DC=example
|
||||||
|
bind_password_file: /etc/llmgw/secrets/ldap-bind # root-only
|
||||||
|
base_dn: OU=Staff,DC=corp,DC=example # where people are
|
||||||
|
user_filter: (memberOf:1.2.840.113556.1.4.1941:=CN=llmgw-users,OU=Groups,DC=corp,DC=example)
|
||||||
|
label: Acme # "Sign in with your Acme account"
|
||||||
|
create_accounts: true
|
||||||
|
roles: # by group CN or full DN; nested groups count
|
||||||
|
admin: [llmgw-admins]
|
||||||
|
operator: [llmgw-ops]
|
||||||
|
memberships:
|
||||||
|
ml-engineers: "team:Acme/ml"
|
||||||
|
sync_interval: 15m # negative turns the sync off
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Service account:** an ordinary domain user is enough: it only reads.
|
||||||
|
It finds the person and reads their groups. The person's password is
|
||||||
|
checked by binding as them, and is never stored.
|
||||||
|
- **Encryption:** `ldaps://`, or `ldap://` with `start_tls: true`.
|
||||||
|
Plaintext LDAP is refused, since passwords would cross the network.
|
||||||
|
- **Certificate:** the domain controller's certificate must name it in
|
||||||
|
its subject alternative names: a name in the CN only is refused, as are
|
||||||
|
certificates with a negative serial number. Samba's own autogenerated
|
||||||
|
certificate is both, so a Samba DC needs one from a CA (`tls certfile`
|
||||||
|
in smb.conf). A CA file a control plane cannot read turns directory
|
||||||
|
sign-in off on that control plane, said in its log. Local accounts still
|
||||||
|
sign in.
|
||||||
|
- **Who may sign in:** enabled user objects under `base_dn` that match
|
||||||
|
`user_filter`. The example admits members of `llmgw-users`, nested groups
|
||||||
|
included.
|
||||||
|
- **Accounts:** an account is tied to the person's `objectGUID`, so a changed
|
||||||
|
mail or name is still the same account. An account that already exists
|
||||||
|
with the person's mail is taken over. Without `create_accounts`, only
|
||||||
|
people who already have an account can sign in. Local accounts, such as
|
||||||
|
the administrator `server init` made, keep signing in with their own
|
||||||
|
password.
|
||||||
|
- **Sync:** every `sync_interval` the leader reads the directory.
|
||||||
|
- Someone disabled or deleted in AD is suspended and their sessions
|
||||||
|
end.
|
||||||
|
- When they are enabled again they are reinstated. An account an
|
||||||
|
administrator suspended by hand stays suspended.
|
||||||
|
- Roles follow groups, and memberships the directory gave are taken away
|
||||||
|
when the person leaves the group.
|
||||||
|
- A sync that would suspend more than half of the directory's accounts is
|
||||||
|
refused and logged, rather than locking everyone out over a wrong
|
||||||
|
`base_dn` or filter.
|
||||||
|
- **Passwords:** a person's password is changed in AD. The gateway sends no
|
||||||
|
reset link for these accounts.
|
||||||
|
|
||||||
## Secrets in Vault
|
## Secrets in Vault
|
||||||
|
|
||||||
Every setting that names a file holding a secret — the store's `@address`,
|
Every setting that names a file holding a secret — the store's `@address`,
|
||||||
@@ -2184,13 +2859,29 @@ llmgw ctl config set firewall.enabled false # every machine's table taken out
|
|||||||
llmgw ctl config set firewall.enabled true # and put back as it was
|
llmgw ctl config set firewall.enabled true # and put back as it was
|
||||||
```
|
```
|
||||||
|
|
||||||
It is in force within seconds: each node's agent takes its table out or puts
|
It is in force on every machine within a minute, each in its own way:
|
||||||
it back, and each control plane's `llmgw-firewall-sync.path` (installed by
|
|
||||||
`server init` and `server add`; `sudo llmgw firewall sync --install` on one
|
- **nodes**: the agent takes its table out or puts it back;
|
||||||
set up before) does the same there. The rules files stay, so turning it back
|
- **control planes**: `llmgw-firewall-sync.path`, installed by `server
|
||||||
on restores them. Nodes added while it is off get none. A separate mirror
|
init` and `server add` (`sudo llmgw firewall sync --install` on one set up
|
||||||
machine and the store's members keep theirs for now. `firewall_networks`
|
before);
|
||||||
changes reach the nodes the same way.
|
- **a separate mirror machine**: the leading control plane tells the mirror,
|
||||||
|
which writes it to `.firewall.json` in its directory, and the same path
|
||||||
|
unit applies it. `mirror init` installs it; on a mirror set up before, run
|
||||||
|
`sudo llmgw mirror init` again with the same flags;
|
||||||
|
- **store members that are nothing else**: they run no llmgw, so
|
||||||
|
`llmgw-firewall-follow.timer` reads what the leading control plane keeps in
|
||||||
|
the store, from the member's own copy, once a minute. `server init
|
||||||
|
--patroni` installs it; run it again on a cluster set up before.
|
||||||
|
|
||||||
|
The rules files stay, so turning it back on restores them. Nodes added while
|
||||||
|
it is off get none. Networks added to `firewall_networks` reach every machine
|
||||||
|
the same way. One taken out of it stays let in until it is taken out of
|
||||||
|
`/etc/llmgw/firewall.nft` by hand: the rules are only ever added to.
|
||||||
|
|
||||||
|
The leading control plane also tells the mirror machine where every node and
|
||||||
|
control plane is, so a node on a network the mirror had not let in can fetch
|
||||||
|
its weights without a `firewall_networks` entry.
|
||||||
|
|
||||||
Only those ports are touched. The table's policy is to accept, so whatever
|
Only those ports are touched. The table's policy is to accept, so whatever
|
||||||
else the machine serves — and whatever firewall it already has — is left as
|
else the machine serves — and whatever firewall it already has — is left as
|
||||||
@@ -2667,6 +3358,8 @@ requests. On a slow model, set `timeouts.generation` higher.
|
|||||||
|
|
||||||
- `docs/choosing-models.md` — which models to run and how many, if the answer
|
- `docs/choosing-models.md` — which models to run and how many, if the answer
|
||||||
to "we have GPUs, now what" is not already obvious
|
to "we have GPUs, now what" is not already obvious
|
||||||
|
- `docs/large-models.md` — a model on several cards or several nodes: how it
|
||||||
|
is split, what each way needs of a card, and bringing one up on a fabric
|
||||||
- `docs/data.md` — what is stored and what leaves the machine, for the review
|
- `docs/data.md` — what is stored and what leaves the machine, for the review
|
||||||
that happens before this is allowed near anyone's documents
|
that happens before this is allowed near anyone's documents
|
||||||
- `docs/reference.md` — every configuration key, generated from the source, so
|
- `docs/reference.md` — every configuration key, generated from the source, so
|
||||||
|
|||||||
+80
-3
@@ -45,6 +45,20 @@ is available to everyone who may use them.
|
|||||||
A collection stays with the model it was made with: vectors from two
|
A collection stays with the model it was made with: vectors from two
|
||||||
models cannot be compared.
|
models cannot be compared.
|
||||||
|
|
||||||
|
Some embedding models are trained with an instruction before the
|
||||||
|
question and none before the documents. Give it as the entry's
|
||||||
|
`query_prefix`; llmgw puts it before questions only. For
|
||||||
|
Qwen3-Embedding:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
query_prefix: "Instruct: Given a web search query, retrieve relevant passages that answer the query\nQuery: "
|
||||||
|
```
|
||||||
|
|
||||||
|
With pgvector 0.7 or later a collection's index is kept at half
|
||||||
|
precision (`halfvec`), which halves its memory and takes models of up to
|
||||||
|
4000 dimensions; with an older pgvector the limit is 2000. A collection
|
||||||
|
keeps the kind it was made with.
|
||||||
|
|
||||||
## For administrators
|
## For administrators
|
||||||
|
|
||||||
### Make a collection
|
### Make a collection
|
||||||
@@ -132,6 +146,24 @@ Rules that apply:
|
|||||||
- Every document added or removed is in the audit log (`llmgw ctl audit`),
|
- Every document added or removed is in the audit log (`llmgw ctl audit`),
|
||||||
by name. The text is never recorded.
|
by name. The text is never recorded.
|
||||||
|
|
||||||
|
### A large import
|
||||||
|
|
||||||
|
A few thousand documents ask the embedding model for far more than it can
|
||||||
|
answer at once. Give the key that sends them a band of its own, below the
|
||||||
|
people's, that may wait for room for minutes rather than the 30 seconds the
|
||||||
|
others wait:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
llmgw ctl config set router.admission.bands '[{name: gold, priority: 300, saturation_ceiling: 0.85},
|
||||||
|
{name: bronze, priority: 100, saturation_ceiling: 0.3}, {name: bulk, priority: 50, saturation_ceiling: 0.3, queue_ttl: 5m}]'
|
||||||
|
llmgw keys create import --project Acme/docs --band bulk
|
||||||
|
```
|
||||||
|
|
||||||
|
The people's questions then go first, and the import waits behind them. A
|
||||||
|
request that waits as long as its band allows is answered 504 with
|
||||||
|
`Retry-After`, and `llmgw-kb` sends it again rather than counting the
|
||||||
|
passage as failed. It gives up after 15 minutes.
|
||||||
|
|
||||||
## For everyone: `llmgw-kb`
|
## For everyone: `llmgw-kb`
|
||||||
|
|
||||||
### Get it
|
### Get it
|
||||||
@@ -317,9 +349,11 @@ Behaviour to know about:
|
|||||||
(Codex, Claude Code) work the same way, since they are answered as chat
|
(Codex, Claude Code) work the same way, since they are answered as chat
|
||||||
completions.
|
completions.
|
||||||
- **Separate prefix caches.** On vLLM replicas, a request given passages
|
- **Separate prefix caches.** On vLLM replicas, a request given passages
|
||||||
carries a `cache_salt` derived from its organization. One organization's
|
carries a `cache_salt` derived from the collections they came from.
|
||||||
passages then never share cached blocks with another's, so how fast an
|
Requests that could be given the same passages still share cached blocks,
|
||||||
answer starts says nothing about what another tenant asked.
|
and no one else's do: not another organization's, nor another team's of
|
||||||
|
the same one. How fast an answer starts therefore says nothing about what
|
||||||
|
someone else asked.
|
||||||
- **No answer cache.** Answers from knowledge bases are never served from
|
- **No answer cache.** Answers from knowledge bases are never served from
|
||||||
the answer cache, because two keys of one project can read different
|
the answer cache, because two keys of one project can read different
|
||||||
collections.
|
collections.
|
||||||
@@ -340,6 +374,49 @@ a product code or a name.
|
|||||||
Metrics: `llmgw_kb_retrievals_total{result}` (found, nothing, refused) and
|
Metrics: `llmgw_kb_retrievals_total{result}` (found, nothing, refused) and
|
||||||
`llmgw_kb_retrieval_seconds`.
|
`llmgw_kb_retrieval_seconds`.
|
||||||
|
|
||||||
|
## Training an adapter on a collection
|
||||||
|
|
||||||
|
An adapter (LoRA) changes how a model *writes*: the organisation's words,
|
||||||
|
format and tone. It does not reliably teach facts. On the PoC, a small
|
||||||
|
model trained on nine policy documents answered 4 of 9 rephrased questions
|
||||||
|
right and mixed facts up on the rest, while the same questions through the
|
||||||
|
knowledge base got 9 of 9. Use both: the knowledge base for what answers
|
||||||
|
say, an adapter for how they sound.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# 1. Questions and answers from the collection, written by a model of the
|
||||||
|
# cluster. Read the file through and correct it.
|
||||||
|
llmgw ctl kb dataset Acme/policies --model qwen2.5-coder-14b-instruct --out policies.jsonl
|
||||||
|
|
||||||
|
# 2. Train on a node. --gpu-memory is what the job needs of a card; it
|
||||||
|
# runs beside an engine when the engine leaves that much free.
|
||||||
|
llmgw ctl train start policies-tone --model qwen2.5-0.5b-instruct \
|
||||||
|
--base-repo Qwen/Qwen2.5-0.5B-Instruct --node cuda01 --gpu-memory 2300 --data policies.jsonl
|
||||||
|
llmgw ctl train show JOB --follow
|
||||||
|
|
||||||
|
# 3. Make it the model's adapter, kept on the mirror.
|
||||||
|
llmgw ctl train publish JOB --restart
|
||||||
|
```
|
||||||
|
|
||||||
|
The node trains with its installed vLLM's torch and a small overlay (peft
|
||||||
|
and accelerate), as the engines' user. For a model llama.cpp serves, it also
|
||||||
|
converts the adapter to GGUF with the installed llama.cpp's own code. What
|
||||||
|
it makes is kept by the control plane (`llmgw ctl train download`).
|
||||||
|
|
||||||
|
An adapter is private like its collection:
|
||||||
|
|
||||||
|
- **It has an owner.** An adapter trained on a collection carries some of
|
||||||
|
what the collection says in its weights, so it belongs to a part of the
|
||||||
|
tree.
|
||||||
|
- By default the owner is the collection's owner.
|
||||||
|
- `--owner` names another, which must be able to read every collection
|
||||||
|
the pairs came from.
|
||||||
|
- Only callers in the owner's part of the tree see the adapter. To anyone
|
||||||
|
else it is a model that does not exist, absent from `/v1/models` too.
|
||||||
|
- **It is public only by choice.** `--public` makes an adapter every
|
||||||
|
caller's. It is refused for pairs from a knowledge base, so it is only for
|
||||||
|
pairs written by hand.
|
||||||
|
|
||||||
## How it is kept apart
|
## How it is kept apart
|
||||||
|
|
||||||
Passages live in PostgreSQL, partitioned by organization, with an index per
|
Passages live in PostgreSQL, partitioned by organization, with an index per
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# llmgw and Kong
|
||||||
|
|
||||||
|
For a site that already runs Kong. The two meet over plain HTTP: Kong in
|
||||||
|
front of llmgw, as one more API it governs, or Kong behind llmgw, as an
|
||||||
|
OpenAI-compatible provider. Nothing of either is built into the other.
|
||||||
|
|
||||||
|
Tried with Kong Gateway 3.9 (`kong:3.9`, the last open-source release; 3.10
|
||||||
|
and later need a Kong licence) in DB-less mode, in front of llmgw.
|
||||||
|
|
||||||
|
## Kong in front of llmgw
|
||||||
|
|
||||||
|
Two ways, both tried.
|
||||||
|
|
||||||
|
**A plain route.** Kong forwards the request as it came. Tried with chat
|
||||||
|
completions, streamed and not; llmgw's other APIs — Anthropic's
|
||||||
|
`/v1/messages`, `/v1/responses`, embeddings, pictures — go the same way,
|
||||||
|
since Kong does not read them. Kong adds its own authentication, rate limits
|
||||||
|
and logging; llmgw still checks the key, the budget and the guardrails.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
_format_version: "3.0"
|
||||||
|
services:
|
||||||
|
- name: llmgw
|
||||||
|
url: https://llm.example.com # llmgw's client port
|
||||||
|
routes:
|
||||||
|
- name: llmgw
|
||||||
|
paths: [/llmgw] # /llmgw/v1/chat/completions → /v1/chat/completions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kong's `ai-proxy` plugin**, for Kong's own token statistics on a chat
|
||||||
|
route. llmgw is an OpenAI provider whose `upstream_url` is llmgw:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
- name: llmgw-chat
|
||||||
|
url: https://llm.example.com
|
||||||
|
routes:
|
||||||
|
- name: chat
|
||||||
|
paths: [/chat]
|
||||||
|
plugins:
|
||||||
|
- name: ai-proxy
|
||||||
|
config:
|
||||||
|
route_type: llm/v1/chat
|
||||||
|
logging: {log_statistics: true, log_payloads: false}
|
||||||
|
model:
|
||||||
|
provider: openai
|
||||||
|
name: qwen-coder # a model llmgw serves
|
||||||
|
options:
|
||||||
|
upstream_url: https://llm.example.com/v1/chat/completions
|
||||||
|
```
|
||||||
|
|
||||||
|
**Keys.** Without an `auth` block, `ai-proxy` passes the client's
|
||||||
|
`Authorization` header on: each team keeps its own llmgw key, and llmgw's
|
||||||
|
budgets, limits, guardrails and usage stay per team. With
|
||||||
|
`auth: {header_name: Authorization, header_value: "Bearer sk-llmgw-…"}` Kong
|
||||||
|
sends one key of its own instead, and llmgw sees every caller as that key —
|
||||||
|
then budgets per team are Kong's to keep.
|
||||||
|
|
||||||
|
**What Kong counts.** For a whole answer Kong reads llmgw's `usage` and
|
||||||
|
records prompt and completion tokens as llmgw did. For a streamed answer
|
||||||
|
Kong 3.9 records an estimate of the prompt tokens and no completion tokens,
|
||||||
|
even with `stream_options.include_usage` set — the usage chunk reaches it
|
||||||
|
and is not read. llmgw's own accounting counts both; use its usage export
|
||||||
|
(`llmgw ctl usage export`) or `/admin/usage` for figures to bill by.
|
||||||
|
|
||||||
|
**Timeouts.** A long answer from a model still loading outlasts Kong's
|
||||||
|
60-second defaults: set the service's `read_timeout` to the time a model may
|
||||||
|
take to start, as for nginx in [install.md](install.md).
|
||||||
|
|
||||||
|
## Kong behind llmgw
|
||||||
|
|
||||||
|
Not tried here. Kong's AI routes speak OpenAI's API, so llmgw reaches models Kong fronts as
|
||||||
|
it reaches any provider: a model whose source is `external`.
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: kong-gpt
|
||||||
|
source: external
|
||||||
|
external:
|
||||||
|
url: https://kong.example.com/openai # Kong's route, without /v1
|
||||||
|
model: gpt-4o-mini
|
||||||
|
api_key_file: /etc/llmgw/secrets/kong-consumer.key
|
||||||
|
```
|
||||||
|
|
||||||
|
The key is the one Kong's own authentication asks of a consumer. As a
|
||||||
|
fallback (`fallbacks: [kong-gpt]`) it is used only when the cluster has no
|
||||||
|
room.
|
||||||
|
|
||||||
|
## Which to choose
|
||||||
|
|
||||||
|
Kong in front when the site's API policy says every API goes through Kong:
|
||||||
|
llmgw is then one more upstream, and its per-team accounting still holds
|
||||||
|
when each team keeps its own key. Kong behind when the site already reaches
|
||||||
|
outside providers through Kong and llmgw should too. llmgw does not need
|
||||||
|
Kong for anything it does itself — keys, budgets, guardrails, routing to
|
||||||
|
its own replicas.
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# Large models: several cards, several nodes
|
||||||
|
|
||||||
|
A model too big for one card runs on several. llmgw chooses how from the
|
||||||
|
model's own figures and the cluster as it is; this page is the rules it
|
||||||
|
follows, the commands, and what to look at when a first one is brought up
|
||||||
|
on new hardware.
|
||||||
|
|
||||||
|
## How a model is split
|
||||||
|
|
||||||
|
In order, the first that fits:
|
||||||
|
|
||||||
|
1. **One card.** Nothing to split.
|
||||||
|
2. **Several cards of one node, tensor parallel.** Every layer is cut
|
||||||
|
across the cards, so they talk for every token: they must be on one
|
||||||
|
NVLink domain. The agent reads `nvidia-smi topo -m` and gives a replica
|
||||||
|
the cards with the most NVLink between them
|
||||||
|
([install.md](install.md#nvlink-if-your-gpus-have-it)).
|
||||||
|
3. **Several nodes, pipeline parallel between them.** Each node holds a
|
||||||
|
run of layers on all its cards (tensor parallel inside it), and only a
|
||||||
|
layer's output crosses to the next node. The nodes must share one
|
||||||
|
InfiniBand or RoCE fabric: a replica never spans two, and never a node
|
||||||
|
with none. Any number of nodes, not only two or four.
|
||||||
|
|
||||||
|
The plan takes the fewest cards, then the fewest nodes, then the better
|
||||||
|
precision up to eight bits.
|
||||||
|
|
||||||
|
**Tensor width and the KV heads.** The cards split the model's KV heads
|
||||||
|
among them, or past their number each card holds a copy of one. So the
|
||||||
|
width divides the KV heads or is a multiple of them: a model with 8 KV
|
||||||
|
heads runs 2, 4, 8 or 16 wide, not 12. Qwen3-235B has 4 and runs on 8.
|
||||||
|
|
||||||
|
**Mixture of experts.** A model of experts (Qwen3-235B-A22B, DeepSeek-V3,
|
||||||
|
Llama 4, Mixtral) on more than one card is served with vLLM's
|
||||||
|
`--enable-expert-parallel`: each card holds whole experts rather than a
|
||||||
|
slice of every one, which is less traffic per token. It is read from
|
||||||
|
`config.json` (`num_experts`, `n_routed_experts`, `num_local_experts`).
|
||||||
|
|
||||||
|
**When it does not fit.** A smaller precision first: an FP8 or AWQ
|
||||||
|
repository of the same model, or a GGUF quantisation on llama.cpp if one
|
||||||
|
node's cards hold it. Then more nodes. `llmgw ctl model plan REPO
|
||||||
|
--precision bf16,fp8` shows each precision's fit from the model's config.
|
||||||
|
|
||||||
|
## What a way needs
|
||||||
|
|
||||||
|
Each way the plan offers says what a replica needs of each card, part by
|
||||||
|
part, and of all its cards together:
|
||||||
|
|
||||||
|
```
|
||||||
|
$ llmgw ctl model plan meta-llama/Llama-3.3-70B-Instruct --format safetensors --context 32768
|
||||||
|
FILE SIZE BEST WAY A CARD OF ALL CARDS NOW
|
||||||
|
original 131 GB 4 × h100-80g 37.4 GB 72 GB 150 GB free
|
||||||
|
|
||||||
|
Best: original on 4 × h100-80g: 37.4 GB a card (weights 32.9 + context 2.5 + engine 2.0) of 72 usable; 150 GB on 4 cards.
|
||||||
|
The context costs 0.31 GB per 1000 tokens, the whole model (--context to plan another).
|
||||||
|
```
|
||||||
|
|
||||||
|
- **weights:** the files, divided over the cards.
|
||||||
|
- **context:** the KV cache for the context planned, divided likewise.
|
||||||
|
- **engine:** the engine's own: 2 GB and a fiftieth of the weights for vLLM.
|
||||||
|
- **usable:** 90% of a card; the rest is the driver's and fragmentation's.
|
||||||
|
|
||||||
|
The console's **Serve a model** shows the same for every way a file runs.
|
||||||
|
A context longer than planned is checked against it before anything is
|
||||||
|
fetched.
|
||||||
|
|
||||||
|
## Serving one
|
||||||
|
|
||||||
|
Without flags the plan's best way is taken:
|
||||||
|
|
||||||
|
```
|
||||||
|
llmgw ctl model serve Qwen/Qwen3-235B-A22B-Instruct-2507-FP8 --format safetensors --context-length 32768
|
||||||
|
```
|
||||||
|
|
||||||
|
Or a way given by hand, for instance two nodes of eight:
|
||||||
|
|
||||||
|
```
|
||||||
|
llmgw ctl model serve meta-llama/Llama-3.3-70B-Instruct --format safetensors \
|
||||||
|
--gpu-class h100-80g --gpus 8 --nodes 2 --env NCCL_DEBUG=INFO
|
||||||
|
```
|
||||||
|
|
||||||
|
`--gpus` is the cards on each node and `--nodes` the nodes. The pipeline
|
||||||
|
is the nodes and the tensor width the cards of each.
|
||||||
|
|
||||||
|
`--env KEY=VALUE` goes into the engine's environment on every node:
|
||||||
|
`NCCL_DEBUG=INFO` while bringing a cluster up, `NCCL_IB_*` to steer it.
|
||||||
|
Only the variables engines are tuned with are taken. llmgw already sets
|
||||||
|
these:
|
||||||
|
- `NCCL_IB_HCA` to the fabric's adapters;
|
||||||
|
- `NCCL_IB_GID_INDEX` on RoCE;
|
||||||
|
- `NCCL_SOCKET_IFNAME` to the management network;
|
||||||
|
- for an NVLink domain spanning nodes, `NCCL_MNNVL_ENABLE`.
|
||||||
|
|
||||||
|
The job's first step says what it needs of each card. The weights then
|
||||||
|
come to the mirror once and from it to each node. The startup timeout (20
|
||||||
|
minutes unless the profile's `startup_timeout` says otherwise) runs from
|
||||||
|
when a node's download last moved. So a model of hundreds of gigabytes is
|
||||||
|
not failed while it is still arriving, and one whose download stops is
|
||||||
|
failed that long after.
|
||||||
|
|
||||||
|
## Watching it come up
|
||||||
|
|
||||||
|
| What | Where |
|
||||||
|
|---|---|
|
||||||
|
| The replica and its shards, one per node, each with its download | `llmgw ctl replicas` |
|
||||||
|
| Why it is not placed | `llmgw ctl explain-placement MODEL` |
|
||||||
|
| A node's engine output (NCCL's lines with `NCCL_DEBUG=INFO`) | `llmgw ctl logs NODE --source SHARD` |
|
||||||
|
| The bandwidth between two nodes, rail by rail | `llmgw ctl fabric-test NODE NODE` |
|
||||||
|
| Each node's GPUs, NVLink and adapters | `llmgw doctor` on the node; its page under /admin |
|
||||||
|
| Every step of a start, across nodes | the `replica start` trace ([install.md](install.md)) |
|
||||||
|
|
||||||
|
With `NCCL_DEBUG=INFO`, NCCL says which transport it took between nodes:
|
||||||
|
`NET/IB` is the fabric. `NET/Socket` means it fell back to TCP, and the
|
||||||
|
model will run, slowly.
|
||||||
|
|
||||||
|
## Bringing it up on new hardware
|
||||||
|
|
||||||
|
A first run on a fabric, in order. Each step is worth its output if
|
||||||
|
something goes wrong.
|
||||||
|
|
||||||
|
1. **The nodes.** Add each with its fabric:
|
||||||
|
`llmgw node add host --fabric ib-a:mlx5_0,mlx5_1`. Then
|
||||||
|
`llmgw ctl nodes` shows every node ready, on `ib-a`.
|
||||||
|
2. **The fabric.** `llmgw ctl fabric-test node1 node2`: each rail at its
|
||||||
|
rated speed (a 400 Gb/s rail near 45 GB/s).
|
||||||
|
3. **One node, several cards.** A dense 70B at bf16 on four or eight
|
||||||
|
cards of one node, by the plan:
|
||||||
|
`llmgw ctl model serve meta-llama/Llama-3.3-70B-Instruct --format safetensors`.
|
||||||
|
Ready, then a question answered.
|
||||||
|
4. **Two nodes.** The same model forced over two:
|
||||||
|
`--gpu-class CLASS --gpus 8 --nodes 2 --env NCCL_DEBUG=INFO`, under
|
||||||
|
another `--name`. Ready, answering, and `NET/IB` in both shards' logs.
|
||||||
|
5. **Experts.** A mixture-of-experts model on one node's eight cards,
|
||||||
|
for instance `Qwen/Qwen3-235B-A22B-Instruct-2507-FP8`: its profile
|
||||||
|
carries `--enable-expert-parallel` (`llmgw ctl models`, or its
|
||||||
|
definition from the admin API).
|
||||||
|
6. **A node lost.** With the two-node replica serving, stop one node's
|
||||||
|
agent (`systemctl stop llmgw-agent`): the replica fails and is placed
|
||||||
|
again once the node is back or another has room. Nothing is left
|
||||||
|
holding cards.
|
||||||
|
|
||||||
|
For each, worth keeping:
|
||||||
|
- the time from serve to ready;
|
||||||
|
- tokens a second from `llmgw ctl bench`;
|
||||||
|
- the plan's per-card figure against what `nvidia-smi` shows once loaded;
|
||||||
|
- anything it said that was wrong.
|
||||||
+13
-3
@@ -54,10 +54,20 @@ on the page that creates it and never again.
|
|||||||
|
|
||||||
### Connecting an editor
|
### Connecting an editor
|
||||||
|
|
||||||
The account page's **Connect your editor** makes a key for Continue, Codex or
|
The account page's **Connect your editor** makes a key for VS Code, Continue,
|
||||||
Cline and shows what the tool needs with that key, this gateway's address and a
|
Codex or Cline and shows what the tool needs with that key, this gateway's
|
||||||
model already in it:
|
address and a model already in it:
|
||||||
|
|
||||||
|
- **VS Code** — llmgw's own extension, downloaded from the gateway at
|
||||||
|
`/setup/vscode/llmgw.vsix` with this gateway's address already in it. It
|
||||||
|
offers every chat model the key may use to VS Code itself: the chat's model
|
||||||
|
picker, agent mode, and any extension that asks VS Code for a language model
|
||||||
|
(Cline's "VS Code LM API" provider among them), with no setup of their own.
|
||||||
|
**llmgw: Sign In** in VS Code opens this page with VS Code chosen; the page's
|
||||||
|
**Open in VS Code** link hands the key back, and the extension keeps it in
|
||||||
|
VS Code's secret storage. A link the extension did not ask for is confirmed
|
||||||
|
first, with the gateway's address shown, since it decides where code is sent.
|
||||||
|
**llmgw: Enter API Key** takes a pasted one instead.
|
||||||
- **Continue** — an assistant file of its own, `~/.continue/assistants/llmgw.yaml`,
|
- **Continue** — an assistant file of its own, `~/.continue/assistants/llmgw.yaml`,
|
||||||
so their `config.yaml` is not touched.
|
so their `config.yaml` is not touched.
|
||||||
- **Codex** — a provider and a profile in `~/.codex/config.toml`, run with
|
- **Codex** — a provider and a profile in `~/.codex/config.toml`, run with
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ explains every choice.
|
|||||||
|
|
||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **A control plane** | One machine, or three for high availability (the store replicated, either one able to take over). Linux, x86-64 or ARM64: Debian 12, Ubuntu 22.04+ or RHEL 10. No GPU needed; 2 GB RAM, 20 GB disk each — 8 GB RAM when it is also a node running the starter model and the two judges. |
|
| **A control plane** | One machine, or three for high availability (the store replicated, either one able to take over). Linux, x86-64 or ARM64: Debian 12, Ubuntu 22.04+ or RHEL 10. No GPU needed; 2 GB RAM, 20 GB disk each — 12 GB RAM when it is also a node running the starter model and the two judges, which setup checks before it installs anything; four cores or more, or the judges on machines of their own, keep a judgment within its wait. |
|
||||||
| **GPU nodes** (optional to start) | The same Linux, with the NVIDIA driver installed. vLLM needs driver 525 or newer and a Turing GPU or newer; a V100 gets an older vLLM. |
|
| **GPU nodes** (optional to start) | The same Linux, with the NVIDIA driver installed. vLLM needs driver 525 or newer and a Turing GPU or newer; a V100 gets an older vLLM. |
|
||||||
| **root** | on the control plane. On the other two of a three-machine setup, `sudo` (its password is asked for if it needs one); on nodes, `sudo` without a password. |
|
| **root** | on the control plane. On the other two of a three-machine setup, `sudo` (its password is asked for if it needs one); on nodes, `sudo` without a password. |
|
||||||
| **SSH** | from the control plane to the other machines. The setup screens take a key, the ssh agent or a password; `node add` uses ssh keys. A machine nothing may log into joins with a code instead (`llmgw join`). |
|
| **SSH** | from the control plane to the other machines. The setup screens take a key, the ssh agent or a password; `node add` uses ssh keys. A machine nothing may log into joins with a code instead (`llmgw join`). |
|
||||||
|
|||||||
+805
-1
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,7 @@ require (
|
|||||||
github.com/charmbracelet/x/ansi v0.11.6
|
github.com/charmbracelet/x/ansi v0.11.6
|
||||||
github.com/coreos/go-oidc/v3 v3.21.0
|
github.com/coreos/go-oidc/v3 v3.21.0
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4
|
github.com/go-jose/go-jose/v4 v4.1.4
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.15
|
||||||
github.com/go-zeromq/zmq4 v0.17.0
|
github.com/go-zeromq/zmq4 v0.17.0
|
||||||
github.com/jackc/pgx/v5 v5.11.0
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
github.com/knadh/koanf/parsers/yaml v1.1.1
|
github.com/knadh/koanf/parsers/yaml v1.1.1
|
||||||
@@ -35,6 +36,7 @@ require (
|
|||||||
golang.org/x/net v0.59.0
|
golang.org/x/net v0.59.0
|
||||||
golang.org/x/oauth2 v0.37.0
|
golang.org/x/oauth2 v0.37.0
|
||||||
golang.org/x/sync v0.23.0
|
golang.org/x/sync v0.23.0
|
||||||
|
golang.org/x/sys v0.48.0
|
||||||
golang.org/x/term v0.46.0
|
golang.org/x/term v0.46.0
|
||||||
golang.org/x/text v0.42.0
|
golang.org/x/text v0.42.0
|
||||||
google.golang.org/protobuf v1.36.12
|
google.golang.org/protobuf v1.36.12
|
||||||
@@ -44,6 +46,7 @@ require (
|
|||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.2.0 // indirect
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
filippo.io/hpke v0.4.0 // indirect
|
filippo.io/hpke v0.4.0 // indirect
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
@@ -56,6 +59,7 @@ require (
|
|||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||||
github.com/go-logr/logr v1.4.4 // indirect
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
@@ -88,7 +92,6 @@ require (
|
|||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.46.0 // indirect
|
go.opentelemetry.io/otel/metric v1.46.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||||
golang.org/x/sys v0.48.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
|
||||||
google.golang.org/grpc v1.83.1 // indirect
|
google.golang.org/grpc v1.83.1 // indirect
|
||||||
|
|||||||
@@ -8,8 +8,12 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
|||||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||||
|
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||||
github.com/NVIDIA/go-nvml v0.13.4-0 h1:o3jp9u2x1R9ShFE3v+Aesp55XOSIQFMJz/VGNUcJaNE=
|
github.com/NVIDIA/go-nvml v0.13.4-0 h1:o3jp9u2x1R9ShFE3v+Aesp55XOSIQFMJz/VGNUcJaNE=
|
||||||
github.com/NVIDIA/go-nvml v0.13.4-0/go.mod h1:id63qwpoDWpFXwnwM6psDCSqW4BmNu6mWpr4YeQtPGo=
|
github.com/NVIDIA/go-nvml v0.13.4-0/go.mod h1:id63qwpoDWpFXwnwM6psDCSqW4BmNu6mWpr4YeQtPGo=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||||
|
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
@@ -46,8 +50,12 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
|
|||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||||
|
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
|
||||||
|
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
@@ -69,6 +77,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
|||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
@@ -81,6 +91,18 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
|||||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
|
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||||
|
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||||
|
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||||
|
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||||
|
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||||
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package account
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeDirectory struct {
|
||||||
|
asked []string
|
||||||
|
u store.User
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeDirectory) SignIn(_ context.Context, login, _ string) (store.User, error) {
|
||||||
|
f.asked = append(f.asked, login)
|
||||||
|
return f.u, f.err
|
||||||
|
}
|
||||||
|
|
||||||
|
// With a directory, a name the store has no password for is the
|
||||||
|
// directory's to check; an account with a password here — the
|
||||||
|
// administrator init made — is still checked here, so a directory that is
|
||||||
|
// down locks nobody out.
|
||||||
|
func TestPasswordsKeptElsewhereAreTheDirectorysToCheck(t *testing.T) {
|
||||||
|
svc, st := newService(t)
|
||||||
|
ctx := t.Context()
|
||||||
|
hash, _ := HashPassword("local-secret")
|
||||||
|
admin := store.User{ID: "usr-admin", Email: "admin@example.com", Name: "a", PasswordHash: hash,
|
||||||
|
State: store.UserActive, CreatedAt: time.Now()}
|
||||||
|
ada := store.User{ID: "usr-ada", Email: "ada@corp.example", Name: "Ada", PasswordHash: "!ldap",
|
||||||
|
State: store.UserActive, CreatedAt: time.Now()}
|
||||||
|
for _, u := range []store.User{admin, ada} {
|
||||||
|
if err := st.CreateUser(ctx, u); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dir := &fakeDirectory{u: ada}
|
||||||
|
svc.SetDirectory(dir)
|
||||||
|
|
||||||
|
if _, _, err := svc.SignIn(ctx, "admin@example.com", "local-secret"); err != nil || len(dir.asked) != 0 {
|
||||||
|
t.Fatalf("the local administrator: %v, directory asked %v", err, dir.asked)
|
||||||
|
}
|
||||||
|
// A name, not an address, and an account kept by the directory.
|
||||||
|
for _, login := range []string{`CORP\ada`, "ada@corp.example"} {
|
||||||
|
if tok, _, err := svc.SignIn(ctx, login, "ad-secret"); err != nil || tok == "" {
|
||||||
|
t.Errorf("%s: %v", login, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(dir.asked) != 2 || dir.asked[0] != `CORP\ada` {
|
||||||
|
t.Errorf("the directory was asked %v", dir.asked)
|
||||||
|
}
|
||||||
|
dir.err = ErrBadCredentials
|
||||||
|
if _, _, err := svc.SignIn(ctx, "ada", "wrong"); !errors.Is(err, ErrBadCredentials) {
|
||||||
|
t.Errorf("a password the directory refused: %v", err)
|
||||||
|
}
|
||||||
|
// A reset link would give a directory's account a password here.
|
||||||
|
if _, _, err := svc.RequestReset(ctx, st, "ada@corp.example"); !errors.Is(err, ErrNoReset) {
|
||||||
|
t.Errorf("a directory's account was sent a reset link: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -80,8 +80,26 @@ type Service struct {
|
|||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
attempts map[string]*attemptWindow
|
attempts map[string]*attemptWindow
|
||||||
|
|
||||||
|
// dir checks the passwords of accounts this store keeps none for.
|
||||||
|
dir Directory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Directory checks a password against an outside directory — Active
|
||||||
|
// Directory over LDAP — and answers the account to sign in: made or kept in
|
||||||
|
// step with the directory. Its errors are this package's ErrBadCredentials
|
||||||
|
// and ErrSuspended for the person's own, and anything else for the
|
||||||
|
// directory's.
|
||||||
|
type Directory interface {
|
||||||
|
SignIn(ctx context.Context, login, password string) (store.User, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDirectory has passwords for accounts with none here, and names that are
|
||||||
|
// not addresses, checked by the directory. An account with a password here
|
||||||
|
// — the administrator init made — is still checked here, so a directory that
|
||||||
|
// is down locks nobody out.
|
||||||
|
func (s *Service) SetDirectory(d Directory) { s.dir = d }
|
||||||
|
|
||||||
type attemptWindow struct {
|
type attemptWindow struct {
|
||||||
count int
|
count int
|
||||||
since time.Time
|
since time.Time
|
||||||
@@ -137,6 +155,7 @@ func (s *Service) SignUp(ctx context.Context, email, name, password string) erro
|
|||||||
// SignIn checks a password and opens a session, returning the session token and
|
// SignIn checks a password and opens a session, returning the session token and
|
||||||
// the CSRF token that must accompany the forms in it.
|
// the CSRF token that must accompany the forms in it.
|
||||||
func (s *Service) SignIn(ctx context.Context, email, password string) (token, csrf string, err error) {
|
func (s *Service) SignIn(ctx context.Context, email, password string) (token, csrf string, err error) {
|
||||||
|
login := strings.TrimSpace(email)
|
||||||
email = store.NormalizeEmail(email)
|
email = store.NormalizeEmail(email)
|
||||||
// Counted per address and the client trying it, so somebody guessing
|
// Counted per address and the client trying it, so somebody guessing
|
||||||
// from elsewhere cannot lock the owner out of their own account; and per
|
// from elsewhere cannot lock the owner out of their own account; and per
|
||||||
@@ -151,6 +170,15 @@ func (s *Service) SignIn(ctx context.Context, email, password string) (token, cs
|
|||||||
}
|
}
|
||||||
|
|
||||||
u, err := s.store.UserByEmail(ctx, email)
|
u, err := s.store.UserByEmail(ctx, email)
|
||||||
|
// No password kept here — the directory's, or nobody's — and a
|
||||||
|
// directory to ask: it decides.
|
||||||
|
if s.dir != nil && (errors.Is(err, store.ErrNotFound) || (err == nil && strings.HasPrefix(u.PasswordHash, "!"))) {
|
||||||
|
if u, err = s.dir.SignIn(ctx, login, password); err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
s.clearAttempts(byClient)
|
||||||
|
return s.open(ctx, u)
|
||||||
|
}
|
||||||
if errors.Is(err, store.ErrNotFound) {
|
if errors.Is(err, store.ErrNotFound) {
|
||||||
// Verify anyway, against a hash that cannot match. Returning early here
|
// Verify anyway, against a hash that cannot match. Returning early here
|
||||||
// would let a stopwatch enumerate every account on the system.
|
// would let a stopwatch enumerate every account on the system.
|
||||||
@@ -180,7 +208,11 @@ func (s *Service) SignIn(ctx context.Context, email, password string) (token, cs
|
|||||||
case store.UserSuspended:
|
case store.UserSuspended:
|
||||||
return "", "", ErrSuspended
|
return "", "", ErrSuspended
|
||||||
}
|
}
|
||||||
|
return s.open(ctx, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// open makes a session for a person whose password was right.
|
||||||
|
func (s *Service) open(ctx context.Context, u store.User) (token, csrf string, err error) {
|
||||||
if token, err = store.NewSessionToken(); err != nil {
|
if token, err = store.NewSessionToken(); err != nil {
|
||||||
return "", "", err
|
return "", "", err
|
||||||
}
|
}
|
||||||
@@ -402,7 +434,10 @@ func (s *Service) RequestReset(ctx context.Context, rs ResetStore, email string)
|
|||||||
return "", store.User{}, ErrTooManyTries
|
return "", store.User{}, ErrTooManyTries
|
||||||
}
|
}
|
||||||
u, err := rs.UserByEmail(ctx, email)
|
u, err := rs.UserByEmail(ctx, email)
|
||||||
if err != nil || u.PasswordHash == "" || u.State != store.UserActive {
|
// An account whose password is kept elsewhere — "!oidc", "!scim",
|
||||||
|
// "!ldap" — gets no link: setting one here would give it a second way
|
||||||
|
// in that its directory knows nothing of, and outlives its leaving.
|
||||||
|
if err != nil || u.PasswordHash == "" || strings.HasPrefix(u.PasswordHash, "!") || u.State != store.UserActive {
|
||||||
return "", store.User{}, ErrNoReset
|
return "", store.User{}, ErrNoReset
|
||||||
}
|
}
|
||||||
token, err := rs.CreatePasswordReset(ctx, u.ID, ResetLinkTTL)
|
token, err := rs.CreatePasswordReset(ctx, u.ID, ResetLinkTTL)
|
||||||
|
|||||||
@@ -323,7 +323,7 @@ func (a *Accountant) Record(res Reservation, u store.Usage) {
|
|||||||
if u.ServedModel != "" && u.ServedModel != u.Model && a.pricing != nil {
|
if u.ServedModel != "" && u.ServedModel != u.Model && a.pricing != nil {
|
||||||
price = a.pricing.For(u.ServedModel)
|
price = a.pricing.For(u.ServedModel)
|
||||||
}
|
}
|
||||||
u.CostMicros = price.Cost(u.PromptTokens, u.CompletionTokens)
|
u.CostMicros = price.Cost(u.PromptTokens, u.CachedTokens, u.CompletionTokens, u.Images, u.Meshes)
|
||||||
for _, b := range res.Budgets {
|
for _, b := range res.Budgets {
|
||||||
a.budgets.Add(b.Counter, b.Period, u.CostMicros)
|
a.budgets.Add(b.Counter, b.Period, u.CostMicros)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,17 +39,44 @@ func TestCostRoundsUpSoSmallRequestsAreNotFree(t *testing.T) {
|
|||||||
// Rounding down would make a stream of tiny requests cost nothing, which is
|
// Rounding down would make a stream of tiny requests cost nothing, which is
|
||||||
// exactly the traffic shape a quota exists to bound.
|
// exactly the traffic shape a quota exists to bound.
|
||||||
p := Price{PromptPer1k: 1000, CompletionPer1k: 3000}
|
p := Price{PromptPer1k: 1000, CompletionPer1k: 3000}
|
||||||
if got := p.Cost(1, 0); got != 1 {
|
if got := p.Cost(1, 0, 0, 0, 0); got != 1 {
|
||||||
t.Errorf("one prompt token cost %d", got)
|
t.Errorf("one prompt token cost %d", got)
|
||||||
}
|
}
|
||||||
if got := p.Cost(1000, 1000); got != 4000 {
|
if got := p.Cost(1000, 0, 1000, 0, 0); got != 4000 {
|
||||||
t.Errorf("a thousand of each cost %d, want 4000", got)
|
t.Errorf("a thousand of each cost %d, want 4000", got)
|
||||||
}
|
}
|
||||||
if got := p.Cost(0, 0); got != 0 {
|
if got := p.Cost(0, 0, 0, 0, 0); got != 0 {
|
||||||
t.Errorf("an empty request cost %d", got)
|
t.Errorf("an empty request cost %d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestACachedPromptCostsTheCachedRate(t *testing.T) {
|
||||||
|
p := Price{PromptPer1k: 1000, CachedPer1k: 100, CompletionPer1k: 3000}
|
||||||
|
// 10,000 prompt tokens of which 9,000 were read from the cache: 1,000 at
|
||||||
|
// the prompt rate and 9,000 at the cached one.
|
||||||
|
if got, want := p.Cost(10000, 9000, 0, 0, 0), int64(1000+900); got != want {
|
||||||
|
t.Errorf("a 90%% cached prompt cost %d, want %d", got, want)
|
||||||
|
}
|
||||||
|
// More cached than prompt is an engine's miscount, not a refund.
|
||||||
|
if got := p.Cost(100, 500, 0, 0, 0); got != 10 {
|
||||||
|
t.Errorf("cached beyond the prompt cost %d, want 10", got)
|
||||||
|
}
|
||||||
|
// No cached rate: cached tokens cost what any prompt token does.
|
||||||
|
if got := (Price{PromptPer1k: 1000}).Cost(10000, 9000, 0, 0, 0); got != 10000 {
|
||||||
|
t.Errorf("without a cached rate a cached prompt cost %d, want 10000", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPictureCostsItsPriceOnTopOfItsPrompt(t *testing.T) {
|
||||||
|
p := Price{PromptPer1k: 1000, PerImage: 50000}
|
||||||
|
if got := p.Cost(20, 0, 0, 3, 0); got != 20+150000 {
|
||||||
|
t.Errorf("three pictures from a 20-token prompt cost %d", got)
|
||||||
|
}
|
||||||
|
if (Price{PerImage: 1}).Zero() {
|
||||||
|
t.Error("a model priced per picture reads as free, and would pass a spent budget")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestZeroPricedModelsStayAvailableWhenTheBudgetIsSpent(t *testing.T) {
|
func TestZeroPricedModelsStayAvailableWhenTheBudgetIsSpent(t *testing.T) {
|
||||||
// This is what makes budget fallback work: a key with nothing left can
|
// This is what makes budget fallback work: a key with nothing left can
|
||||||
// still be served something that costs nothing.
|
// still be served something that costs nothing.
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ import (
|
|||||||
type Price struct {
|
type Price struct {
|
||||||
PromptPer1k int64 `koanf:"prompt_per_1k"`
|
PromptPer1k int64 `koanf:"prompt_per_1k"`
|
||||||
CompletionPer1k int64 `koanf:"completion_per_1k"`
|
CompletionPer1k int64 `koanf:"completion_per_1k"`
|
||||||
|
// CachedPer1k is what a prompt token costs that the engine or provider
|
||||||
|
// read from its cache instead of computing: the part of prompt_tokens
|
||||||
|
// the answer counts as cached_tokens. Zero charges those at
|
||||||
|
// PromptPer1k, as before there was a rate for them.
|
||||||
|
CachedPer1k int64 `koanf:"cached_per_1k"`
|
||||||
|
// PerImage is what one picture costs, for a model that makes them: a
|
||||||
|
// picture's cost is the card's time, which its prompt's few tokens do
|
||||||
|
// not measure.
|
||||||
|
PerImage int64 `koanf:"per_image"`
|
||||||
|
// PerMesh is what one 3D model costs: minutes of a card, which no
|
||||||
|
// token count measures.
|
||||||
|
PerMesh int64 `koanf:"per_mesh"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Zero reports whether this model spends nothing.
|
// Zero reports whether this model spends nothing.
|
||||||
@@ -25,14 +37,26 @@ type Price struct {
|
|||||||
// It matters beyond arithmetic: a zero-cost model stays available to a key
|
// It matters beyond arithmetic: a zero-cost model stays available to a key
|
||||||
// whose budget is spent, which is what makes the plan's budget fallback work
|
// whose budget is spent, which is what makes the plan's budget fallback work
|
||||||
// (§7.7).
|
// (§7.7).
|
||||||
func (p Price) Zero() bool { return p.PromptPer1k == 0 && p.CompletionPer1k == 0 }
|
func (p Price) Zero() bool {
|
||||||
|
return p.PromptPer1k == 0 && p.CompletionPer1k == 0 && p.CachedPer1k == 0 && p.PerImage == 0 && p.PerMesh == 0
|
||||||
|
}
|
||||||
|
|
||||||
// Cost computes what a request spent, rounding up.
|
// Cost computes what a request spent, rounding up.
|
||||||
//
|
//
|
||||||
// Up rather than to nearest: rounding down would make a stream of tiny requests
|
// Up rather than to nearest: rounding down would make a stream of tiny requests
|
||||||
// free, which is exactly the shape of traffic a quota exists to bound.
|
// free, which is exactly the shape of traffic a quota exists to bound.
|
||||||
func (p Price) Cost(promptTokens, completionTokens int64) int64 {
|
//
|
||||||
return divUp(promptTokens*p.PromptPer1k, 1000) + divUp(completionTokens*p.CompletionPer1k, 1000)
|
// cachedTokens are counted within promptTokens, as OpenAI's usage counts
|
||||||
|
// them, and are charged at the cached rate instead of the prompt rate.
|
||||||
|
func (p Price) Cost(promptTokens, cachedTokens, completionTokens, images, meshes int64) int64 {
|
||||||
|
cachedTokens = min(max(cachedTokens, 0), max(promptTokens, 0))
|
||||||
|
cachedRate := p.CachedPer1k
|
||||||
|
if cachedRate == 0 {
|
||||||
|
cachedRate = p.PromptPer1k
|
||||||
|
}
|
||||||
|
return divUp((promptTokens-cachedTokens)*p.PromptPer1k+cachedTokens*cachedRate, 1000) +
|
||||||
|
divUp(completionTokens*p.CompletionPer1k, 1000) +
|
||||||
|
max(images, 0)*p.PerImage + max(meshes, 0)*p.PerMesh
|
||||||
}
|
}
|
||||||
|
|
||||||
func divUp(n, d int64) int64 {
|
func divUp(n, d int64) int64 {
|
||||||
|
|||||||
+53
-4
@@ -6,6 +6,7 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -34,6 +35,7 @@ import (
|
|||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/train"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
|
||||||
@@ -140,6 +142,9 @@ type Agent struct {
|
|||||||
// settingsCheck says whether the cluster's settings for this agent
|
// settingsCheck says whether the cluster's settings for this agent
|
||||||
// would load beside its agent.yaml; see SetSettingsCheck.
|
// would load beside its agent.yaml; see SetSettingsCheck.
|
||||||
settingsCheck func([]byte) error
|
settingsCheck func([]byte) error
|
||||||
|
|
||||||
|
// trainer runs LoRA training jobs, one at a time (BACKLOG F4).
|
||||||
|
trainer *train.Runner
|
||||||
}
|
}
|
||||||
|
|
||||||
// connectHTTPClient is the HTTP client connect uses; an interface so tests can
|
// connectHTTPClient is the HTTP client connect uses; an interface so tests can
|
||||||
@@ -186,6 +191,18 @@ func New(cfg Config, httpClient connectHTTPClient, log *slog.Logger) (*Agent, er
|
|||||||
kvSubs: map[string]*kvevents.Subscriber{},
|
kvSubs: map[string]*kvevents.Subscriber{},
|
||||||
}
|
}
|
||||||
a.prov = &provision.Installer{Root: cfg.EnginesDir, Log: log, OnStatus: a.provisionStatus}
|
a.prov = &provision.Installer{Root: cfg.EnginesDir, Log: log, OnStatus: a.provisionStatus}
|
||||||
|
runAs := cfg.Superv.RunAs
|
||||||
|
if runAs == "" {
|
||||||
|
runAs = supervisor.DefaultRunAs
|
||||||
|
}
|
||||||
|
// Where the engines are, as the installer has it when the file says
|
||||||
|
// nothing.
|
||||||
|
enginesDir := cfg.EnginesDir
|
||||||
|
if enginesDir == "" {
|
||||||
|
enginesDir = provision.DefaultRoot
|
||||||
|
}
|
||||||
|
a.trainer = train.New(train.Env{EnginesDir: enginesDir, WorkDir: filepath.Join(cfg.Superv.WorkDir, "trainer"),
|
||||||
|
RunAs: runAs, Log: log})
|
||||||
return a, nil
|
return a, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,6 +903,10 @@ func (a *Agent) handle(ctx context.Context, msg *llmgwv1.ControlMessage, out cha
|
|||||||
go a.applyFirewall(m.Firewall)
|
go a.applyFirewall(m.Firewall)
|
||||||
case *llmgwv1.ControlMessage_AgentSettings:
|
case *llmgwv1.ControlMessage_AgentSettings:
|
||||||
go a.applySettings(m.AgentSettings)
|
go a.applySettings(m.AgentSettings)
|
||||||
|
case *llmgwv1.ControlMessage_TrainStart:
|
||||||
|
go a.trainJob(ctx, m.TrainStart, out)
|
||||||
|
case *llmgwv1.ControlMessage_TrainCancel:
|
||||||
|
a.trainer.Cancel(m.TrainCancel.GetJobId())
|
||||||
case *llmgwv1.ControlMessage_RestartAgent:
|
case *llmgwv1.ControlMessage_RestartAgent:
|
||||||
go func() {
|
go func() {
|
||||||
a.log.Info("restarting, as the control plane asks; the engines keep serving",
|
a.log.Info("restarting, as the control plane asks; the engines keep serving",
|
||||||
@@ -1111,6 +1132,31 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
|
|||||||
stop()
|
stop()
|
||||||
spec.Profile.ModelPath = path
|
spec.Profile.ModelPath = path
|
||||||
}
|
}
|
||||||
|
// A shared ComfyUI replica's members' weights, each fetched like the
|
||||||
|
// model's, and handed to the gate where they landed.
|
||||||
|
if raw := spec.Profile.Env[engines.SharedModelsEnv]; raw != "" {
|
||||||
|
var ms []map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(raw), &ms); err != nil {
|
||||||
|
report(supervisor.Failed, "the shared replica's models are not readable: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i, m := range ms {
|
||||||
|
stop := a.watchFetch(m["source"], func(p weights.Progress) {
|
||||||
|
send(&llmgwv1.ShardStatus{State: agentsvc.StateToWire(supervisor.Starting),
|
||||||
|
Fetch: &llmgwv1.FetchProgress{Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal}})
|
||||||
|
})
|
||||||
|
path, err := a.weights().Ensure(ctx, m["source"])
|
||||||
|
stop()
|
||||||
|
if err != nil {
|
||||||
|
report(supervisor.Failed, fmt.Sprintf("model %s: %v", m["name"], err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ms[i]["path"] = path
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(ms)
|
||||||
|
spec.Profile.Env = maps.Clone(spec.Profile.Env)
|
||||||
|
spec.Profile.Env[engines.SharedModelsEnv] = string(b)
|
||||||
|
}
|
||||||
// Adapters are fetched like the model, and before the engine starts: it
|
// Adapters are fetched like the model, and before the engine starts: it
|
||||||
// loads them at start, and one missing is a replica that answers for the
|
// loads them at start, and one missing is a replica that answers for the
|
||||||
// base model under the adapter's name.
|
// base model under the adapter's name.
|
||||||
@@ -1371,11 +1417,14 @@ func Client(cfg tlsconf.Config, timeout time.Duration) (*http.Client, error) {
|
|||||||
// off — is never closed: nothing sends the FIN, and the agent's heartbeats sit
|
// off — is never closed: nothing sends the FIN, and the agent's heartbeats sit
|
||||||
// in the socket's buffer until the kernel gives up retransmitting, fifteen
|
// in the socket's buffer until the kernel gives up retransmitting, fifteen
|
||||||
// minutes later. Found by switching off a leader: the standby took over in 14
|
// minutes later. Found by switching off a leader: the standby took over in 14
|
||||||
// seconds and the node stayed with the dead one. An HTTP/2 ping after ten
|
// seconds and the node stayed with the dead one. An HTTP/2 ping after five
|
||||||
// quiet seconds, given five to answer, finds it in a quarter of a minute.
|
// quiet seconds, given three to answer, finds it in eight: by the time the
|
||||||
|
// standby leads, as it does within the lease's ten seconds, the node is on
|
||||||
|
// its way to it. At fifteen, with the lease at fifteen too, the new leader
|
||||||
|
// went without its nodes, and so its judges, for half a minute.
|
||||||
const (
|
const (
|
||||||
streamIdle = 10 * time.Second
|
streamIdle = 5 * time.Second
|
||||||
streamPing = 5 * time.Second
|
streamPing = 3 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// localizeSpec fills in what only this node knows.
|
// localizeSpec fills in what only this node knows.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package discovery
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"os"
|
"os"
|
||||||
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -142,17 +143,29 @@ func cpuFlagsFrom(path string) []string {
|
|||||||
// nvidiaDriverFrom reads the loaded kernel module's version, which exists
|
// nvidiaDriverFrom reads the loaded kernel module's version, which exists
|
||||||
// exactly when a driver is loaded — unlike nvidia-smi, which can be installed
|
// exactly when a driver is loaded — unlike nvidia-smi, which can be installed
|
||||||
// on a machine whose module failed to load.
|
// on a machine whose module failed to load.
|
||||||
|
var driverVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+(\.[0-9]+)?$`)
|
||||||
|
|
||||||
func nvidiaDriverFrom(path string) string {
|
func nvidiaDriverFrom(path string) string {
|
||||||
b, err := os.ReadFile(path) //nolint:gosec // a fixed system path
|
b, err := os.ReadFile(path) //nolint:gosec // a fixed system path
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
// "NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu ..."
|
// "NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu ...", or
|
||||||
|
// for the open module, the default since 560, "NVIDIA UNIX Open Kernel
|
||||||
|
// Module for x86_64 580.82.07 Release Build ...": the first version
|
||||||
|
// after "Module", not the word after it. Read as "for", the driver was
|
||||||
|
// unknown, and every build was tried whatever driver it needs.
|
||||||
fields := strings.Fields(string(b))
|
fields := strings.Fields(string(b))
|
||||||
for i, f := range fields {
|
for i, f := range fields {
|
||||||
if f == "Module" && i+1 < len(fields) {
|
if f != "Module" {
|
||||||
return fields[i+1]
|
continue
|
||||||
}
|
}
|
||||||
|
for _, v := range fields[i+1:] {
|
||||||
|
if driverVersion.MatchString(v) {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNVIDIADriverFrom(t *testing.T) {
|
||||||
|
for _, tc := range []struct{ text, want string }{
|
||||||
|
{"NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu Jan 23 21:30:38 UTC 2025\nGCC version: gcc version 12.2.0\n", "570.86.15"},
|
||||||
|
{"NVRM version: NVIDIA UNIX Open Kernel Module for x86_64 580.82.07 Release Build (dvs-builder@U16-I3-B03-4-3) Wed Aug 27 00:51:12 UTC 2025\n", "580.82.07"},
|
||||||
|
{"NVRM version: NVIDIA UNIX Open Kernel Module for aarch64 580.95.05 Release Build\n", "580.95.05"},
|
||||||
|
{"NVRM version: NVIDIA UNIX x86_64 Kernel Module 535.54 Mon\n", "535.54"},
|
||||||
|
{"something else\n", ""},
|
||||||
|
} {
|
||||||
|
p := filepath.Join(t.TempDir(), "version")
|
||||||
|
if err := os.WriteFile(p, []byte(tc.text), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := nvidiaDriverFrom(p); got != tc.want {
|
||||||
|
t.Errorf("%q: got %q, want %q", tc.text, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -74,6 +74,13 @@ func NewSensorSampler() *SensorSampler {
|
|||||||
return &SensorSampler{sys: "/sys", smi: runSMI, now: time.Now}
|
return &SensorSampler{sys: "/sys", smi: runSMI, now: time.Now}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SampleGPUs reads every card's heat, power and how busy it is now, without
|
||||||
|
// a sampler's ten seconds of memory: for a screen that asks every few seconds
|
||||||
|
// and wants what it asked for. Nil on a machine without nvidia-smi or a card.
|
||||||
|
func SampleGPUs(ctx context.Context) []GPUSensor {
|
||||||
|
return (&SensorSampler{smi: runSMI}).gpus(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func runSMI(ctx context.Context, fields string) ([]byte, error) {
|
func runSMI(ctx context.Context, fields string) ([]byte, error) {
|
||||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package engines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ComfyUIConfig locates a ComfyUI installation.
|
||||||
|
type ComfyUIConfig struct {
|
||||||
|
// Dir is ComfyUI's directory, where main.py is. Readable by the user
|
||||||
|
// engines run as.
|
||||||
|
Dir string `koanf:"dir"`
|
||||||
|
// Python is the interpreter of its environment; Dir's .venv when unset.
|
||||||
|
Python string `koanf:"python"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComfyUI runs ComfyUI as an engine, behind llmgw's gate (llmgw
|
||||||
|
// comfyui-gate): ComfyUI asks for no key, so it listens on the loopback and
|
||||||
|
// the gate, which does, on the replica's port. Its replicas make pictures
|
||||||
|
// for /v1/images/generations.
|
||||||
|
type ComfyUI struct {
|
||||||
|
cfg ComfyUIConfig
|
||||||
|
installed atomic.Pointer[string]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name identifies the engine in the catalog.
|
||||||
|
func (*ComfyUI) Name() string { return "comfyui" }
|
||||||
|
|
||||||
|
// HealthPath is the gate's, which answers once ComfyUI does.
|
||||||
|
func (*ComfyUI) HealthPath() string { return "/health" }
|
||||||
|
|
||||||
|
// MetricsPath is empty: ComfyUI publishes no metrics, and the router reads
|
||||||
|
// none from it.
|
||||||
|
func (*ComfyUI) MetricsPath() string { return "" }
|
||||||
|
|
||||||
|
// ServesHTTP is true: a ComfyUI replica is one process on one node.
|
||||||
|
func (*ComfyUI) ServesHTTP(ShardSpec) bool { return true }
|
||||||
|
|
||||||
|
// UseInstalled points the engine at the environment the agent installed:
|
||||||
|
// its interpreter, with ComfyUI's code beside its bin, in comfyui/.
|
||||||
|
func (c *ComfyUI) UseInstalled(python string) { c.installed.Store(&python) }
|
||||||
|
|
||||||
|
// Configured reports whether the configuration names a ComfyUI.
|
||||||
|
func (c *ComfyUI) Configured() bool { return c.cfg.Dir != "" }
|
||||||
|
|
||||||
|
func (c *ComfyUI) dir() string {
|
||||||
|
if p := c.installed.Load(); p != nil {
|
||||||
|
return filepath.Join(filepath.Dir(filepath.Dir(*p)), "comfyui")
|
||||||
|
}
|
||||||
|
return c.cfg.Dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ComfyUI) python() string {
|
||||||
|
if p := c.installed.Load(); p != nil {
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
if c.cfg.Python != "" {
|
||||||
|
return c.cfg.Python
|
||||||
|
}
|
||||||
|
return filepath.Join(c.cfg.Dir, ".venv", "bin", "python")
|
||||||
|
}
|
||||||
|
|
||||||
|
var comfyVersion = regexp.MustCompile(`__version__\s*=\s*"([^"]+)"`)
|
||||||
|
|
||||||
|
// Probe reports ComfyUI's version, from the file its build writes it to:
|
||||||
|
// importing ComfyUI to ask would load torch and look for cards.
|
||||||
|
func (c *ComfyUI) Probe(context.Context) (string, error) {
|
||||||
|
dir := c.dir()
|
||||||
|
if dir == "" {
|
||||||
|
return "", fmt.Errorf("no ComfyUI directory is configured")
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "main.py")); err != nil {
|
||||||
|
return "", fmt.Errorf("%s is not ComfyUI: %w", dir, err)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, "comfyui_version.py"))
|
||||||
|
if err != nil {
|
||||||
|
return Unknown, nil //nolint:nilerr // installed, of a version it does not say
|
||||||
|
}
|
||||||
|
if m := comfyVersion.FindSubmatch(b); m != nil {
|
||||||
|
return string(m[1]), nil
|
||||||
|
}
|
||||||
|
return Unknown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the profile: one card, on one node. ComfyUI runs a
|
||||||
|
// workflow on one device and has no tensor parallelism to spread it by.
|
||||||
|
func (*ComfyUI) Validate(p Profile, gpus []GPUInfo) error {
|
||||||
|
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
|
||||||
|
return fmt.Errorf("profile %q: ComfyUI runs on one GPU of one node", p.Name)
|
||||||
|
}
|
||||||
|
return validateCommon(p, gpus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands is the gate, which starts ComfyUI itself.
|
||||||
|
func (c *ComfyUI) Commands(s ShardSpec) ([]Command, error) {
|
||||||
|
dir := c.dir()
|
||||||
|
if dir == "" {
|
||||||
|
return nil, fmt.Errorf("comfyui: no ComfyUI directory is configured (engines.comfyui.dir)")
|
||||||
|
}
|
||||||
|
bin, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p := s.Profile
|
||||||
|
args := []string{"comfyui-gate",
|
||||||
|
"--listen", net.JoinHostPort(s.Host(), strconv.Itoa(s.HTTPPort)),
|
||||||
|
"--model", p.ServedModelName,
|
||||||
|
"--weights", p.ModelPath,
|
||||||
|
// Relative to where engines run — their user's home — so it is
|
||||||
|
// theirs to write.
|
||||||
|
"--state", filepath.Join("comfyui", s.ReplicaID),
|
||||||
|
"--python", c.python(),
|
||||||
|
"--comfyui", dir,
|
||||||
|
"--",
|
||||||
|
}
|
||||||
|
args = append(args, p.Args...)
|
||||||
|
env := baseEnv(s)
|
||||||
|
if s.APIKey != "" {
|
||||||
|
env["LLMGW_ENGINE_KEY"] = s.APIKey
|
||||||
|
}
|
||||||
|
// A model's custom nodes and the repository a node loads its weights by,
|
||||||
|
// which only the catalog sets — a profile's env may not name them — and
|
||||||
|
// so taken before baseEnv drops them. Without the repository the gate
|
||||||
|
// links no Hugging Face cache, and sf3d's node went to the network for
|
||||||
|
// its gated repository and was refused with a 401.
|
||||||
|
for _, k := range []string{comfyNodesEnv, comfyHFRepoEnv, SharedModelsEnv} {
|
||||||
|
if v := p.Env[k]; v != "" {
|
||||||
|
env[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []Command{{Name: "comfyui", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// comfyNodesEnv is the custom nodes the catalog gives a ComfyUI model, as
|
||||||
|
// JSON; catalog.NodesEnv and comfy.NodesEnv by the same name.
|
||||||
|
const comfyNodesEnv = "LLMGW_COMFYUI_NODES"
|
||||||
|
|
||||||
|
// comfyHFRepoEnv is the repository a custom node loads the weights by name
|
||||||
|
// from; catalog.HFRepoEnv and comfy.HFRepoEnv by the same name.
|
||||||
|
const comfyHFRepoEnv = "LLMGW_COMFYUI_HF_REPO"
|
||||||
|
|
||||||
|
// SharedModelsEnv is a shared replica's members — name, source, and the
|
||||||
|
// path the agent fetched it to — as JSON; catalog.SharedModelsEnv and
|
||||||
|
// comfy.SharedModelsEnv by the same name.
|
||||||
|
const SharedModelsEnv = "LLMGW_COMFYUI_MODELS"
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package engines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComfyUIRunsBehindTheGateWithItsKeyOutOfSight(t *testing.T) {
|
||||||
|
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/comfyui"}}
|
||||||
|
cmds, err := c.Commands(ShardSpec{
|
||||||
|
ReplicaID: "r-flux-1", HTTPPort: 8100, BindAddr: "10.0.0.147", APIKey: "sekret", GPUIndices: []int{0},
|
||||||
|
Profile: Profile{Name: "p", Engine: "comfyui", ModelPath: "/w/flux.safetensors", ServedModelName: "flux",
|
||||||
|
Args: []string{"--disable-smart-memory"}},
|
||||||
|
})
|
||||||
|
if err != nil || len(cmds) != 1 {
|
||||||
|
t.Fatalf("%v %v", cmds, err)
|
||||||
|
}
|
||||||
|
args := strings.Join(cmds[0].Args, " ")
|
||||||
|
for _, want := range []string{"comfyui-gate", "--listen 10.0.0.147:8100", "--model flux", "--weights /w/flux.safetensors",
|
||||||
|
"--state comfyui/r-flux-1", "--python /opt/comfyui/.venv/bin/python", "--comfyui /opt/comfyui", "-- --disable-smart-memory"} {
|
||||||
|
if !strings.Contains(args, want) {
|
||||||
|
t.Errorf("%q not in %q", want, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(args, "sekret") {
|
||||||
|
t.Error("the key is on the command line, where every user can read it")
|
||||||
|
}
|
||||||
|
if !slices.Contains(cmds[0].Env, "LLMGW_ENGINE_KEY=sekret") || !slices.Contains(cmds[0].Env, "CUDA_VISIBLE_DEVICES=0") {
|
||||||
|
t.Error("the key or the card is not in the environment")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheGateIsToldTheNodesAndTheRepositoryTheCatalogGives(t *testing.T) {
|
||||||
|
// The repository was dropped on the way: sf3d's node went to the network
|
||||||
|
// for its gated repository, with no cache linked, and got a 401.
|
||||||
|
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/comfyui"}}
|
||||||
|
cmds, err := c.Commands(ShardSpec{
|
||||||
|
ReplicaID: "r-sf3d-1", HTTPPort: 8100, BindAddr: "10.0.0.147", GPUIndices: []int{0},
|
||||||
|
Profile: Profile{Name: "p", Engine: "comfyui", ModelPath: "/w/sf3d/", ServedModelName: "sf3d",
|
||||||
|
Env: map[string]string{comfyNodesEnv: `[{"repo":"x"}]`, comfyHFRepoEnv: "stabilityai/stable-fast-3d"}},
|
||||||
|
})
|
||||||
|
if err != nil || len(cmds) != 1 {
|
||||||
|
t.Fatalf("%v %v", cmds, err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{comfyNodesEnv + `=[{"repo":"x"}]`, comfyHFRepoEnv + "=stabilityai/stable-fast-3d"} {
|
||||||
|
if !slices.Contains(cmds[0].Env, want) {
|
||||||
|
t.Errorf("%q not in the gate's environment", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComfyUIRunsOnOneCard(t *testing.T) {
|
||||||
|
c := &ComfyUI{}
|
||||||
|
if err := c.Validate(Profile{Name: "p", ModelPath: "/w", TP: 2}, h100(2)); err == nil {
|
||||||
|
t.Error("a two-card ComfyUI profile was accepted")
|
||||||
|
}
|
||||||
|
if err := c.Validate(Profile{Name: "p", ModelPath: "/w", TP: 1}, h100(1)); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if _, err := c.Commands(ShardSpec{Profile: Profile{ModelPath: "/w"}}); err == nil {
|
||||||
|
t.Error("commands without a ComfyUI directory")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComfyUIIsOfferedByTheVersionItsBuildWrote(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
c := &ComfyUI{cfg: ComfyUIConfig{Dir: dir}}
|
||||||
|
if _, err := c.Probe(t.Context()); err == nil {
|
||||||
|
t.Error("a directory without main.py passed for ComfyUI")
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, "main.py"), nil, 0o644)
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, "comfyui_version.py"), []byte("__version__ = \"0.39.2\"\n"), 0o644)
|
||||||
|
if v, err := c.Probe(t.Context()); err != nil || v != "0.39.2" {
|
||||||
|
t.Errorf("%q %v", v, err)
|
||||||
|
}
|
||||||
|
if got := NewRegistry(Config{ComfyUI: ComfyUIConfig{Dir: dir}}).ProbeConfigured(t.Context()); got["comfyui"] != "0.39.2" {
|
||||||
|
t.Errorf("a configured ComfyUI is reported as %v", got)
|
||||||
|
}
|
||||||
|
if err := UnsafeArgs("comfyui", []string{"--enable-manager"}); err == nil {
|
||||||
|
t.Error("a profile may install ComfyUI nodes")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnInstalledComfyUIIsFoundBesideItsInterpreter(t *testing.T) {
|
||||||
|
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/by-hand"}}
|
||||||
|
c.UseInstalled("/opt/llmgw/engines/comfyui/0.39.2-cu13/bin/python")
|
||||||
|
cmds, err := c.Commands(ShardSpec{ReplicaID: "r", HTTPPort: 1, Profile: Profile{ModelPath: "/w", ServedModelName: "m"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
args := strings.Join(cmds[0].Args, " ")
|
||||||
|
if !strings.Contains(args, "--python /opt/llmgw/engines/comfyui/0.39.2-cu13/bin/python") ||
|
||||||
|
!strings.Contains(args, "--comfyui /opt/llmgw/engines/comfyui/0.39.2-cu13/comfyui") {
|
||||||
|
t.Errorf("the installed ComfyUI is not what runs: %s", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -214,6 +214,7 @@ func NewRegistry(cfg Config) *Registry {
|
|||||||
r.Add(&LlamaCpp{cfg: cfg.LlamaCpp})
|
r.Add(&LlamaCpp{cfg: cfg.LlamaCpp})
|
||||||
r.Add(&TRTLLM{cfg: cfg.TRTLLM})
|
r.Add(&TRTLLM{cfg: cfg.TRTLLM})
|
||||||
r.Add(&Sim{cfg: cfg.Sim})
|
r.Add(&Sim{cfg: cfg.Sim})
|
||||||
|
r.Add(&ComfyUI{cfg: cfg.ComfyUI})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -262,6 +263,8 @@ type Config struct {
|
|||||||
// offers it only when its interpreter is named here.
|
// offers it only when its interpreter is named here.
|
||||||
TRTLLM TRTLLMConfig `koanf:"trtllm"`
|
TRTLLM TRTLLMConfig `koanf:"trtllm"`
|
||||||
Sim SimConfig `koanf:"sim"`
|
Sim SimConfig `koanf:"sim"`
|
||||||
|
// ComfyUI makes pictures; offered by a node whose ComfyUI is named here.
|
||||||
|
ComfyUI ComfyUIConfig `koanf:"comfyui"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- shared helpers ---
|
// --- shared helpers ---
|
||||||
|
|||||||
@@ -477,7 +477,7 @@ func TestValidateRequiresModelPath(t *testing.T) {
|
|||||||
|
|
||||||
func TestRegistry(t *testing.T) {
|
func TestRegistry(t *testing.T) {
|
||||||
r := NewRegistry(Config{})
|
r := NewRegistry(Config{})
|
||||||
if got := r.Names(); !slices.Equal(got, []string{"llamacpp", "sglang", "sim", "trtllm", "vllm"}) {
|
if got := r.Names(); !slices.Equal(got, []string{"comfyui", "llamacpp", "sglang", "sim", "trtllm", "vllm"}) {
|
||||||
t.Errorf("registry contents: %v", got)
|
t.Errorf("registry contents: %v", got)
|
||||||
}
|
}
|
||||||
if _, err := r.Get("vllm"); err != nil {
|
if _, err := r.Get("vllm"); err != nil {
|
||||||
|
|||||||
@@ -119,6 +119,19 @@ var flagsNever = map[string]map[string]string{
|
|||||||
"--model-path": "is the model's source, set by the agent", "--served-model-name": "is set by the agent",
|
"--model-path": "is the model's source, set by the agent", "--served-model-name": "is set by the agent",
|
||||||
"--lora-paths": "loads files", "--download-dir": "writes files",
|
"--lora-paths": "loads files", "--download-dir": "writes files",
|
||||||
},
|
},
|
||||||
|
// ComfyUI's main.py, behind the gate that sets where it listens and
|
||||||
|
// where its files are.
|
||||||
|
"comfyui": {
|
||||||
|
"--listen": "is set by the agent", "--base-directory": "is set by the agent",
|
||||||
|
"--extra-model-paths-config": "is set by the agent",
|
||||||
|
"--output-directory": "writes files", "--temp-directory": "writes files",
|
||||||
|
"--input-directory": "reads files", "--user-directory": "writes files",
|
||||||
|
"--database-url": "writes a database elsewhere", "--front-end-root": "serves a directory",
|
||||||
|
"--enable-cors-header": "lets other sites' pages call it",
|
||||||
|
"--enable-manager": "installs nodes, which run Python",
|
||||||
|
"--tls-keyfile": "reads a file", "--tls-certfile": "reads a file",
|
||||||
|
"--comfy-api-base": "sends work to another service",
|
||||||
|
},
|
||||||
// trtllm-serve spells its flags with underscores, so the shared list's
|
// trtllm-serve spells its flags with underscores, so the shared list's
|
||||||
// hyphenated names do not reach them.
|
// hyphenated names do not reach them.
|
||||||
"trtllm": {
|
"trtllm": {
|
||||||
|
|||||||
@@ -0,0 +1,177 @@
|
|||||||
|
package provision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// comfyuiSource is a ComfyUI release archive as GitHub makes one, and the
|
||||||
|
// recipe pointed at it.
|
||||||
|
func comfyuiSource(t *testing.T, in *Installer, rel *release, digest string) {
|
||||||
|
t.Helper()
|
||||||
|
archive := tarGz(t, map[string]string{
|
||||||
|
"ComfyUI-0.39.2/main.py": "print('comfy')\n",
|
||||||
|
"ComfyUI-0.39.2/comfyui_version.py": "__version__ = \"0.39.2\"\n",
|
||||||
|
"ComfyUI-0.39.2/requirements.txt": "comfyui-frontend-package==1.53.10\ntorch\ntorchvision\n" +
|
||||||
|
"numpy>=1.25.0\n\n#non essential dependencies:\nkornia>=0.7.1\n",
|
||||||
|
}, nil)
|
||||||
|
rel.assets["v0.39.2.tar.gz"] = archive
|
||||||
|
sum := sha256.Sum256(archive)
|
||||||
|
if digest == "" {
|
||||||
|
digest = hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
old := comfyuiReleases
|
||||||
|
comfyuiReleases = map[string]struct {
|
||||||
|
sha256 string
|
||||||
|
published string
|
||||||
|
}{"0.39.2": {digest, "2026-10-07T21:58:07Z"}}
|
||||||
|
t.Cleanup(func() { comfyuiReleases = old })
|
||||||
|
}
|
||||||
|
|
||||||
|
var turingNode = discovery.Host{OSID: "ubuntu", OSVersion: "24.04", Arch: "amd64",
|
||||||
|
NVIDIADriver: "580.82.07", MinComputeCapability: 7.5}
|
||||||
|
|
||||||
|
func TestComfyUIIsInstalledFromItsSourceCheckedAgainstTheRecipe(t *testing.T) {
|
||||||
|
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||||
|
in, rel := pythonInstaller(t, turingNode, sys)
|
||||||
|
comfyuiSource(t, in, rel, "")
|
||||||
|
|
||||||
|
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
|
||||||
|
if got[0].State != Ready {
|
||||||
|
t.Fatalf("not ready: %+v", got[0])
|
||||||
|
}
|
||||||
|
dir := filepath.Join(in.Root, "comfyui", "0.39.2-cu13")
|
||||||
|
if got[0].Binary != filepath.Join(dir, "bin", "python") {
|
||||||
|
t.Errorf("binary %s, want the environment's interpreter", got[0].Binary)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "comfyui", "main.py")); err != nil {
|
||||||
|
t.Errorf("ComfyUI's code is not beside the interpreter: %v", err)
|
||||||
|
}
|
||||||
|
all := strings.Join(sys.commands, "\n")
|
||||||
|
for _, want := range []string{
|
||||||
|
// Not PyPI's date, which ComfyUI has none of: the recipe's, a day on.
|
||||||
|
"--exclude-newer 2026-10-08T21:58:07Z",
|
||||||
|
"comfyui-frontend-package==1.53.10", "numpy>=1.25.0", "kornia>=0.7.1", "torch",
|
||||||
|
"--require-hashes",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(all, want) {
|
||||||
|
t.Errorf("no %q in:\n%s", want, all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(all, "/pypi/") {
|
||||||
|
t.Error("PyPI was asked when ComfyUI was published")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComfyUIOnAnOlderDriverTakesPyTorchsCUDA12Torch(t *testing.T) {
|
||||||
|
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||||
|
host := turingNode
|
||||||
|
host.NVIDIADriver = "550.163.01"
|
||||||
|
in, rel := pythonInstaller(t, host, sys)
|
||||||
|
comfyuiSource(t, in, rel, "")
|
||||||
|
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
|
||||||
|
if got[0].State != Ready || got[0].Variant != "cu129" {
|
||||||
|
t.Fatalf("got %+v", got[0])
|
||||||
|
}
|
||||||
|
var project string
|
||||||
|
for _, c := range sys.commands {
|
||||||
|
if strings.HasPrefix(c, "pyproject: ") {
|
||||||
|
project = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// torch from PyTorch's index alone, named once, as the build names it.
|
||||||
|
if !strings.Contains(project, `torch = { index = "extra" }`) || strings.Count(project, `"torch"`) != 1 {
|
||||||
|
t.Errorf("torch is not from PyTorch's index alone:\n%s", project)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A V100 on driver 580: CUDA 13 runs on the driver, but neither its torch
|
||||||
|
// nor CUDA 12.9's has kernels for Volta. Tried in turn, each was installed,
|
||||||
|
// 10 GB at a time, and failed on the card.
|
||||||
|
func TestComfyUIOnAVoltaTakesPyTorchsCUDA126Torch(t *testing.T) {
|
||||||
|
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||||
|
host := turingNode
|
||||||
|
host.MinComputeCapability = 7.0
|
||||||
|
in, rel := pythonInstaller(t, host, sys)
|
||||||
|
comfyuiSource(t, in, rel, "")
|
||||||
|
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
|
||||||
|
if got[0].State != Ready || got[0].Variant != "cu126" {
|
||||||
|
t.Fatalf("got %+v", got[0])
|
||||||
|
}
|
||||||
|
all := strings.Join(sys.commands, "\n")
|
||||||
|
if !strings.Contains(all, pytorchCU126) || strings.Contains(all, pytorchCU129) {
|
||||||
|
t.Errorf("torch is not CUDA 12.6's:\n%s", all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComfyUIWhoseSourceIsNotTheOneTheRecipeNamesIsNotInstalled(t *testing.T) {
|
||||||
|
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||||
|
in, rel := pythonInstaller(t, turingNode, sys)
|
||||||
|
comfyuiSource(t, in, rel, strings.Repeat("ab", 32))
|
||||||
|
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
|
||||||
|
if got[0].State != Failed || !strings.Contains(got[0].Detail, "does not match") {
|
||||||
|
t.Fatalf("got %s %q", got[0].State, got[0].Detail)
|
||||||
|
}
|
||||||
|
for _, c := range sys.commands {
|
||||||
|
if strings.Contains(c, "pip install") {
|
||||||
|
t.Errorf("installed from a source that did not match: %s", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestComfyUIIsInstalledOnlyWhereItCanRun(t *testing.T) {
|
||||||
|
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||||
|
in, _ := pythonInstaller(t, ubuntu, sys)
|
||||||
|
if got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}}); got[0].State != Skipped {
|
||||||
|
t.Errorf("a node with no GPU: %+v", got[0])
|
||||||
|
}
|
||||||
|
in, _ = pythonInstaller(t, turingNode, sys)
|
||||||
|
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.1.0"}})
|
||||||
|
if got[0].State != Unsupported || !strings.Contains(got[0].Detail, "knows the source digest of ComfyUI 0.39.2") {
|
||||||
|
t.Errorf("a version without a digest: %s %q", got[0].State, got[0].Detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestARequirementsFileIsReadForPackagesAndNothingElse(t *testing.T) {
|
||||||
|
got, err := requirementsIn("torch\ntorchvision>=0.20\nPillow\n# a comment\naiohttp>=3.11 # inline\n", pytorchPackages)
|
||||||
|
if err != nil || strings.Join(got, ",") != "Pillow,aiohttp>=3.11" {
|
||||||
|
t.Errorf("%v %v", got, err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"-r other.txt", "--index-url https://evil", "x @ https://evil/x.whl", "-e ."} {
|
||||||
|
if _, err := requirementsIn(bad+"\n", nil); err == nil {
|
||||||
|
t.Errorf("%q was accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A card every build runs on keeps the order it had: a Turing node's
|
||||||
|
// installed cu13 is still the first found, and is not installed again
|
||||||
|
// because a build for older cards was added after it.
|
||||||
|
func TestComfyUIBuildsAreTriedBestFirst(t *testing.T) {
|
||||||
|
variants := func(h discovery.Host) string {
|
||||||
|
builds, err := comfyuiCandidates("0.39.2", "", h)
|
||||||
|
if err != nil {
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
var v []string
|
||||||
|
for _, b := range builds {
|
||||||
|
v = append(v, b.Variant)
|
||||||
|
}
|
||||||
|
return strings.Join(v, ",")
|
||||||
|
}
|
||||||
|
volta := turingNode
|
||||||
|
volta.MinComputeCapability = 7.0
|
||||||
|
older := turingNode
|
||||||
|
older.NVIDIADriver = "550.163.01"
|
||||||
|
for h, want := range map[*discovery.Host]string{&turingNode: "cu13,cu129,cu126", &volta: "cu126", &older: "cu129,cu126"} {
|
||||||
|
if got := variants(*h); got != want {
|
||||||
|
t.Errorf("cc %.1f driver %s: %s, want %s", h.MinComputeCapability, h.NVIDIADriver, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -66,12 +66,17 @@ func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Buil
|
|||||||
return fmt.Errorf("installing uv, which installs %s: %s", w.Engine, uv.Detail)
|
return fmt.Errorf("installing uv, which installs %s: %s", w.Engine, uv.Detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
in.progress(st, "asking PyPI when %s %s was published", r.PyPI, w.Version)
|
var asOf string
|
||||||
published, err := in.published(ctx, r.PyPI, w.Version)
|
if !b.AsOf.IsZero() {
|
||||||
if err != nil {
|
asOf = b.AsOf.UTC().Format(time.RFC3339)
|
||||||
return err
|
} else {
|
||||||
|
in.progress(st, "asking PyPI when %s %s was published", r.PyPI, w.Version)
|
||||||
|
published, err := in.published(ctx, r.PyPI, w.Version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
asOf = published.Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
asOf := published.Add(24 * time.Hour).UTC().Format(time.RFC3339)
|
|
||||||
|
|
||||||
cache := filepath.Join(in.root(), ".uv-cache")
|
cache := filepath.Join(in.root(), ".uv-cache")
|
||||||
env := []string{
|
env := []string{
|
||||||
@@ -96,7 +101,14 @@ func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Buil
|
|||||||
}
|
}
|
||||||
python := filepath.Join(tmp, "bin", "python")
|
python := filepath.Join(tmp, "bin", "python")
|
||||||
|
|
||||||
reqs := b.Requirements
|
reqs := slices.Clone(b.Requirements)
|
||||||
|
if b.Source != nil {
|
||||||
|
more, err := in.unpackSource(ctx, w, b, tmp, st)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
reqs = append(reqs, more...)
|
||||||
|
}
|
||||||
if b.Wheel != nil {
|
if b.Wheel != nil {
|
||||||
// The engine from its GitHub release, pinned to the digest GitHub
|
// The engine from its GitHub release, pinned to the digest GitHub
|
||||||
// publishes for the file: the same trust as an archive, and uv
|
// publishes for the file: the same trust as an archive, and uv
|
||||||
@@ -320,3 +332,68 @@ func (in *Installer) gpuCheck(ctx context.Context, python string, w Want, b Buil
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unpackSource fetches an engine's source archive, checks it against the
|
||||||
|
// digest the recipe holds, puts its code in the environment, and returns the
|
||||||
|
// requirements its requirements file names — less the packages a second
|
||||||
|
// index gives, which the build names itself.
|
||||||
|
func (in *Installer) unpackSource(ctx context.Context, w Want, b Build, env string, st *Status) ([]string, error) {
|
||||||
|
src := b.Source
|
||||||
|
url := src.URL
|
||||||
|
if strings.HasPrefix(url, "/") {
|
||||||
|
url = strings.TrimSuffix(in.downloadBase(), "/") + url
|
||||||
|
}
|
||||||
|
scratch := env + ".source"
|
||||||
|
_ = os.RemoveAll(scratch)
|
||||||
|
if err := os.MkdirAll(scratch, 0o755); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(scratch) //nolint:errcheck // scratch
|
||||||
|
archive := filepath.Join(scratch, "source.tar.gz")
|
||||||
|
name := w.Engine + " " + w.Version + "'s source"
|
||||||
|
if err := in.download(ctx, url, name, archive, src.SHA256, st); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
in.progress(st, "unpacking %s", name)
|
||||||
|
if err := untarGz(archive, scratch); err != nil {
|
||||||
|
return nil, fmt.Errorf("unpacking %s: %w", name, err)
|
||||||
|
}
|
||||||
|
dst := filepath.Join(env, src.Dir)
|
||||||
|
if err := os.Rename(filepath.Join(scratch, src.Top), dst); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s has no %s at its top: %w", name, src.Top, err)
|
||||||
|
}
|
||||||
|
if src.Requirements == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
f, err := os.ReadFile(filepath.Join(dst, src.Requirements))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", name, err)
|
||||||
|
}
|
||||||
|
return requirementsIn(string(f), b.IndexPackages)
|
||||||
|
}
|
||||||
|
|
||||||
|
// requirementsIn reads a requirements file's lines. Options (-r, -e, --index-url
|
||||||
|
// and the like) are refused: they would fetch from somewhere other than PyPI,
|
||||||
|
// or install what the lock never saw.
|
||||||
|
func requirementsIn(text string, skip []string) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
line, _, _ = strings.Cut(line, "#")
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(line, "-") || strings.Contains(line, "://") || strings.Contains(line, " @ ") {
|
||||||
|
return nil, fmt.Errorf("the requirements name %q, which is not a package from PyPI", line)
|
||||||
|
}
|
||||||
|
name := strings.ToLower(line)
|
||||||
|
if i := strings.IndexAny(name, "<>=!~;[ "); i >= 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
if slices.Contains(skip, name) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, line)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ func (f *fakePython) run(ctx context.Context, env []string, name string, args ..
|
|||||||
return []byte("error: Hash mismatch for torch\n"), errExit
|
return []byte("error: Hash mismatch for torch\n"), errExit
|
||||||
}
|
}
|
||||||
venv := filepath.Dir(filepath.Dir(args[slices.Index(args, "--python")+1]))
|
venv := filepath.Dir(filepath.Dir(args[slices.Index(args, "--python")+1]))
|
||||||
|
_ = os.WriteFile(filepath.Join(venv, "bin", "python"), []byte("#!/bin/sh\n"), 0o755)
|
||||||
return nil, os.WriteFile(filepath.Join(venv, "bin", "vllm"), []byte("#!/bin/sh\n"), 0o755)
|
return nil, os.WriteFile(filepath.Join(venv, "bin", "vllm"), []byte("#!/bin/sh\n"), 0o755)
|
||||||
case strings.HasSuffix(name, "/bin/vllm") && args[0] == "--version":
|
case strings.HasSuffix(name, "/bin/vllm") && args[0] == "--version":
|
||||||
return []byte("0.30.0\n"), nil
|
return []byte("0.30.0\n"), nil
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||||
)
|
)
|
||||||
@@ -68,12 +69,31 @@ type Build struct {
|
|||||||
// Note says why this build, when it is not the plain one: shown beside
|
// Note says why this build, when it is not the plain one: shown beside
|
||||||
// it wherever the installation is.
|
// it wherever the installation is.
|
||||||
Note string
|
Note string
|
||||||
|
// Source, for a Python engine that is not on PyPI, is its own code: an
|
||||||
|
// archive pinned by its digest, unpacked into the environment, whose
|
||||||
|
// requirements file is installed with Requirements. AsOf is then the
|
||||||
|
// date its dependencies are resolved as of — the day after its version
|
||||||
|
// was published, which PyPI cannot be asked for.
|
||||||
|
Source *Source
|
||||||
|
AsOf time.Time
|
||||||
// FreeGB is the disk space the installation needs before it starts: a
|
// FreeGB is the disk space the installation needs before it starts: a
|
||||||
// Python engine is several gigabytes, and one that fills the disk halfway
|
// Python engine is several gigabytes, and one that fills the disk halfway
|
||||||
// leaves the node worse off than one that never began.
|
// leaves the node worse off than one that never began.
|
||||||
FreeGB int
|
FreeGB int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source is an engine's code as an archive.
|
||||||
|
type Source struct {
|
||||||
|
// URL is fetched as is; SHA256 is what it must hash to, written into
|
||||||
|
// the recipe because a project's source archives come with no published
|
||||||
|
// digest of their own.
|
||||||
|
URL, SHA256 string
|
||||||
|
// Top is the archive's one top directory, put in the environment as Dir.
|
||||||
|
Top, Dir string
|
||||||
|
// Requirements is the requirements file, in Dir.
|
||||||
|
Requirements string
|
||||||
|
}
|
||||||
|
|
||||||
// Wheel is a package published as a GitHub release asset: the asset whose
|
// Wheel is a package published as a GitHub release asset: the asset whose
|
||||||
// name starts with Prefix and ends with Suffix.
|
// name starts with Prefix and ends with Suffix.
|
||||||
type Wheel struct {
|
type Wheel struct {
|
||||||
@@ -104,6 +124,7 @@ var Recipes = map[string]Recipe{
|
|||||||
"llamacpp": {Repo: "ggml-org/llama.cpp", Candidates: llamaCppCandidates},
|
"llamacpp": {Repo: "ggml-org/llama.cpp", Candidates: llamaCppCandidates},
|
||||||
"vllm": {PyPI: "vllm", Candidates: vllmCandidates},
|
"vllm": {PyPI: "vllm", Candidates: vllmCandidates},
|
||||||
"sglang": {PyPI: "sglang", Candidates: sglangCandidates},
|
"sglang": {PyPI: "sglang", Candidates: sglangCandidates},
|
||||||
|
"comfyui": {Candidates: comfyuiCandidates},
|
||||||
"uv": {Repo: "astral-sh/uv", Candidates: uvCandidates},
|
"uv": {Repo: "astral-sh/uv", Candidates: uvCandidates},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,3 +577,98 @@ func orUnknown(s string) string {
|
|||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- ComfyUI ---
|
||||||
|
|
||||||
|
// comfyuiReleases are the ComfyUI versions this agent installs: each source
|
||||||
|
// archive's digest, taken when it was added here and confirmed from a second
|
||||||
|
// machine, and when the version was published. GitHub publishes no digest
|
||||||
|
// for a tag's archive, so a version not listed is refused rather than
|
||||||
|
// installed unchecked.
|
||||||
|
var comfyuiReleases = map[string]struct {
|
||||||
|
sha256 string
|
||||||
|
published string
|
||||||
|
}{
|
||||||
|
"0.39.2": {"1d655e22cf546700fc1d6d2536e27701695c5832a51f63bb16c41866ac09be0b", "2026-10-07T21:58:07Z"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComfyUIVersion is the newest ComfyUI this agent installs: what a cluster
|
||||||
|
// is set to when a model first needs it.
|
||||||
|
func ComfyUIVersion() string {
|
||||||
|
newest := ""
|
||||||
|
for v := range comfyuiReleases {
|
||||||
|
if newest == "" || versionBefore(newest, v) {
|
||||||
|
newest = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return newest
|
||||||
|
}
|
||||||
|
|
||||||
|
// ComfyUIRepo is where ComfyUI's source is published.
|
||||||
|
const ComfyUIRepo = "Comfy-Org/ComfyUI"
|
||||||
|
|
||||||
|
// comfyuiDir is where ComfyUI's code is put in its environment: main.py is
|
||||||
|
// <environment>/comfyui/main.py, beside bin/python.
|
||||||
|
const comfyuiDir = "comfyui"
|
||||||
|
|
||||||
|
// pytorchCU126 is PyTorch's CUDA 12.6 build: the last that carries kernels
|
||||||
|
// for Volta and Pascal. From torch 2.8 its CUDA 12.8 and 12.9 builds start
|
||||||
|
// at Turing (7.5), as CUDA 13 does, so a V100 has no torch but this one.
|
||||||
|
const pytorchCU126 = "https://download.pytorch.org/whl/cu126"
|
||||||
|
|
||||||
|
// comfyuiCandidates are the ComfyUI builds for a machine: its source, in a
|
||||||
|
// Python environment with the torch for the node's driver and cards —
|
||||||
|
// PyPI's, built for CUDA 13; PyTorch's CUDA 12.9 build for a driver CUDA 13
|
||||||
|
// does not run on; and its CUDA 12.6 build for cards older than Turing.
|
||||||
|
func comfyuiCandidates(version, variant string, h discovery.Host) ([]Build, error) {
|
||||||
|
if _, err := gpuArch("ComfyUI", h); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rel, ok := comfyuiReleases[version]
|
||||||
|
if !ok {
|
||||||
|
known := make([]string, 0, len(comfyuiReleases))
|
||||||
|
for v := range comfyuiReleases {
|
||||||
|
known = append(known, v)
|
||||||
|
}
|
||||||
|
slices.Sort(known)
|
||||||
|
return nil, fmt.Errorf("this agent knows the source digest of ComfyUI %s, not of %s; "+
|
||||||
|
"install that one by hand and name it in agent.yaml (engines.comfyui.dir)",
|
||||||
|
strings.Join(known, ", "), version)
|
||||||
|
}
|
||||||
|
published, err := time.Parse(time.RFC3339, rel.published)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
build := func(variant string) Build {
|
||||||
|
return Build{
|
||||||
|
Variant: variant,
|
||||||
|
Python: pythonForEngines,
|
||||||
|
Source: &Source{
|
||||||
|
URL: "/" + ComfyUIRepo + "/archive/refs/tags/v" + version + ".tar.gz",
|
||||||
|
SHA256: rel.sha256, Top: "ComfyUI-" + version, Dir: comfyuiDir,
|
||||||
|
Requirements: "requirements.txt",
|
||||||
|
},
|
||||||
|
AsOf: published.Add(24 * time.Hour),
|
||||||
|
// ComfyUI is started as a script, so what the agent is given is
|
||||||
|
// the environment's interpreter; the proof is importing what it
|
||||||
|
// imports first, from where its code is.
|
||||||
|
Binary: "python",
|
||||||
|
VersionArgs: []string{"-c", `import os, sys, torch, aiohttp, safetensors
|
||||||
|
d = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(sys.executable))), "` + comfyuiDir + `")
|
||||||
|
assert os.path.isfile(os.path.join(d, "main.py")), d + " has no main.py"
|
||||||
|
print("comfyui version", "` + version + `", "on torch", torch.__version__)`},
|
||||||
|
Packages: compilerPackages,
|
||||||
|
FreeGB: 15,
|
||||||
|
GPUCheck: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cu129 := build("cu129")
|
||||||
|
cu129.ExtraIndex, cu129.IndexPackages = pytorchCU129, pytorchPackages
|
||||||
|
cu126 := build("cu126")
|
||||||
|
cu126.ExtraIndex, cu126.IndexPackages = pytorchCU126, pytorchPackages
|
||||||
|
return pick("ComfyUI "+version, variant, []candidate{
|
||||||
|
{build("cu13"), gpuNeeds{minDriverForCUDA13, 7.5}, "ComfyUI " + version + " on CUDA 13's torch"},
|
||||||
|
{cu129, gpuNeeds{minDriverForCUDA12, 7.5}, "ComfyUI " + version + " on CUDA 12.9's torch"},
|
||||||
|
{cu126, gpuNeeds{minDriverForCUDA12, 5.0}, "ComfyUI " + version + " on CUDA 12.6's torch"},
|
||||||
|
}, h)
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package supervisor
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An engine's output goes to a file of its own, not to a pipe of the
|
||||||
|
// agent's. A pipe has one reader, and that reader is the agent that
|
||||||
|
// started the engine: when that agent restarts, the engine it left running
|
||||||
|
// — and adopts a moment later — writes to a pipe nobody reads, and its next
|
||||||
|
// write past the pipe's buffer fails with EPIPE. A Python engine's progress
|
||||||
|
// bar then raises, and takes the request it was reporting on with it. A
|
||||||
|
// file has no reader to lose: the engine appends to it, every agent reads
|
||||||
|
// it, and the output from before a restart is there for the one after.
|
||||||
|
|
||||||
|
// maxOutputBytes bounds one process's file: past it the file is emptied and
|
||||||
|
// the process goes on appending to it. A progress bar rewriting itself for
|
||||||
|
// an hour would otherwise fill the disk.
|
||||||
|
const maxOutputBytes = 64 << 20
|
||||||
|
|
||||||
|
// outputPath is where a shard's process writes.
|
||||||
|
func (s *Supervisor) outputPath(shardID, name string) string {
|
||||||
|
if name != "" {
|
||||||
|
return filepath.Join(s.cfg.WorkDir, "shard-"+shardID+"."+name+".log")
|
||||||
|
}
|
||||||
|
return filepath.Join(s.cfg.WorkDir, "shard-"+shardID+".log")
|
||||||
|
}
|
||||||
|
|
||||||
|
// openOutput is the file a process is started with: new, for a process
|
||||||
|
// that is new.
|
||||||
|
func (s *Supervisor) openOutput(shardID, name string) (*os.File, error) {
|
||||||
|
return os.OpenFile(s.outputPath(shardID, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_TRUNC, 0o600) //nolint:gosec // the agent's own directory
|
||||||
|
}
|
||||||
|
|
||||||
|
// follow reads a process's file into the shard's log as it is written,
|
||||||
|
// until the shard's output is stopped and the file has been read to its
|
||||||
|
// end. A file an earlier agent began is read from the start, so the output
|
||||||
|
// from before a restart is in the log too.
|
||||||
|
func (s *Supervisor) follow(sh *Shard, path, name string) {
|
||||||
|
defer sh.outDone.Done()
|
||||||
|
f, err := os.Open(path) //nolint:gosec // the agent's own directory
|
||||||
|
if err != nil {
|
||||||
|
sh.logs.add("[" + name + "] output unavailable: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
var offset int64
|
||||||
|
var rest []byte
|
||||||
|
buf := make([]byte, 64<<10)
|
||||||
|
read := func() {
|
||||||
|
for {
|
||||||
|
st, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if st.Size() < offset {
|
||||||
|
// Emptied, by this agent or another: begin again.
|
||||||
|
offset, rest = 0, nil
|
||||||
|
}
|
||||||
|
if st.Size() > maxOutputBytes {
|
||||||
|
_ = os.Truncate(path, 0)
|
||||||
|
offset, rest = 0, nil
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, err := f.ReadAt(buf, offset)
|
||||||
|
if n > 0 {
|
||||||
|
offset += int64(n)
|
||||||
|
rest = append(rest, buf[:n]...)
|
||||||
|
for {
|
||||||
|
i := bytes.IndexByte(rest, '\n')
|
||||||
|
if i < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.addLine(sh, name, rest[:i])
|
||||||
|
rest = rest[i+1:]
|
||||||
|
}
|
||||||
|
// A progress bar is a line rewritten with \r and never ended:
|
||||||
|
// its latest state is what is wanted, not a line a megabyte
|
||||||
|
// long. Kept, as llama.cpp and tqdm write them.
|
||||||
|
if len(rest) > 1<<20 {
|
||||||
|
s.addLine(sh, name, rest)
|
||||||
|
rest = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil || n == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t := time.NewTicker(200 * time.Millisecond)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-sh.outStop:
|
||||||
|
read()
|
||||||
|
if len(rest) > 0 {
|
||||||
|
s.addLine(sh, name, rest)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
read()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Supervisor) addLine(sh *Shard, name string, b []byte) {
|
||||||
|
// The last state of a line rewritten with \r, as a terminal would show.
|
||||||
|
if i := bytes.LastIndexByte(b, '\r'); i >= 0 {
|
||||||
|
b = b[i+1:]
|
||||||
|
}
|
||||||
|
line := string(b)
|
||||||
|
if name != "" {
|
||||||
|
// Prefixed so the two processes of a Ray shard stay distinguishable
|
||||||
|
// in a single log tail.
|
||||||
|
line = "[" + name + "] " + line
|
||||||
|
}
|
||||||
|
sh.logs.add(line)
|
||||||
|
s.log.Debug("engine", "shard", sh.ID, "line", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// removeOutput drops a shard's files once nothing writes to them.
|
||||||
|
func (s *Supervisor) removeOutput(shardID string) {
|
||||||
|
matches, _ := filepath.Glob(filepath.Join(s.cfg.WorkDir, "shard-"+shardID+".*log"))
|
||||||
|
for _, m := range matches {
|
||||||
|
_ = os.Remove(m)
|
||||||
|
}
|
||||||
|
_ = os.Remove(s.outputPath(shardID, ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopOutput has every follower read what is left and stop. Called once
|
||||||
|
// the process has ended, before the shard is done, so what it wrote last
|
||||||
|
// is in the log by the time anyone reads the log for why it ended.
|
||||||
|
func (s *Supervisor) stopOutput(sh *Shard) {
|
||||||
|
sh.outOnce.Do(func() { close(sh.outStop) })
|
||||||
|
sh.outDone.Wait()
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ package supervisor
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"os/user"
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -90,3 +91,25 @@ func credentialFor(u *user.User, workDir string) (*runAs, error) {
|
|||||||
func (r *runAs) env() []string {
|
func (r *runAs) env() []string {
|
||||||
return []string{"HOME=" + r.home, "XDG_CACHE_HOME=" + filepath.Join(r.home, ".cache"), "USER=" + r.name, "LOGNAME=" + r.name}
|
return []string{"HOME=" + r.home, "XDG_CACHE_HOME=" + filepath.Join(r.home, ".cache"), "USER=" + r.name, "LOGNAME=" + r.name}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AsEngineUser has cmd run as the engine user, as an engine would, with its
|
||||||
|
// home under workDir: for work the agent does that is not an engine but
|
||||||
|
// reads what an engine reads — training an adapter. It answers that home
|
||||||
|
// and a way to hand a path to the user, or why it runs as this process.
|
||||||
|
func AsEngineUser(cmd *exec.Cmd, name, workDir string) (home string, give func(path string) error, why string) {
|
||||||
|
r, why := resolveRunAs(name, workDir)
|
||||||
|
if r == nil {
|
||||||
|
return "", func(string) error { return nil }, why
|
||||||
|
}
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: r.cred, Setpgid: true}
|
||||||
|
cmd.Env = append(cmd.Env, r.env()...)
|
||||||
|
uid, gid := int(r.cred.Uid), int(r.cred.Gid)
|
||||||
|
return r.home, func(path string) error {
|
||||||
|
return filepath.WalkDir(path, func(p string, _ os.DirEntry, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.Lchown(p, uid, gid)
|
||||||
|
})
|
||||||
|
}, ""
|
||||||
|
}
|
||||||
@@ -1,12 +1,10 @@
|
|||||||
package supervisor
|
package supervisor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
@@ -104,6 +102,12 @@ type Shard struct {
|
|||||||
ID string
|
ID string
|
||||||
Spec engines.ShardSpec
|
Spec engines.ShardSpec
|
||||||
|
|
||||||
|
// outStop ends the followers of the processes' output files, and
|
||||||
|
// outDone is their having read to the end.
|
||||||
|
outStop chan struct{}
|
||||||
|
outOnce sync.Once
|
||||||
|
outDone sync.WaitGroup
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
state State
|
state State
|
||||||
pid int
|
pid int
|
||||||
@@ -248,6 +252,7 @@ func (s *Supervisor) Start(ctx context.Context, id string, spec engines.ShardSpe
|
|||||||
logs: newRingBuffer(s.cfg.LogLines),
|
logs: newRingBuffer(s.cfg.LogLines),
|
||||||
started: time.Now(),
|
started: time.Now(),
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
outStop: make(chan struct{}),
|
||||||
}
|
}
|
||||||
s.shards[id] = sh
|
s.shards[id] = sh
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -315,14 +320,15 @@ func (s *Supervisor) launch(ctx context.Context, sh *Shard, cmds []engines.Comma
|
|||||||
// CommandContext kills only the leader, so replace that with a group kill.
|
// CommandContext kills only the leader, so replace that with a group kill.
|
||||||
c.Cancel = func() error { return killGroup(c.Process.Pid, syscall.SIGKILL) }
|
c.Cancel = func() error { return killGroup(c.Process.Pid, syscall.SIGKILL) }
|
||||||
|
|
||||||
stdout, err := c.StdoutPipe()
|
out, err := s.openOutput(sh.ID, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.Stderr = c.Stdout.(io.Writer) // interleave, as the engine writes them
|
c.Stdout, c.Stderr = out, out // interleaved, as the engine writes them
|
||||||
|
err = c.Start()
|
||||||
if err := c.Start(); err != nil {
|
out.Close() // the process holds its own
|
||||||
|
if err != nil {
|
||||||
cancel()
|
cancel()
|
||||||
return fmt.Errorf("starting %s: %w", cmd.Path, err)
|
return fmt.Errorf("starting %s: %w", cmd.Path, err)
|
||||||
}
|
}
|
||||||
@@ -339,7 +345,8 @@ func (s *Supervisor) launch(ctx context.Context, sh *Shard, cmds []engines.Comma
|
|||||||
"shard", sh.ID, "err", err)
|
"shard", sh.ID, "err", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
go s.pump(sh, stdout, cmd.Name)
|
sh.outDone.Add(1)
|
||||||
|
go s.follow(sh, s.outputPath(sh.ID, ""), cmd.Name)
|
||||||
go s.wait(sh)
|
go s.wait(sh)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -353,16 +360,18 @@ func (s *Supervisor) spawn(ctx context.Context, sh *Shard, c engines.Command) (*
|
|||||||
s.dropTo(proc)
|
s.dropTo(proc)
|
||||||
proc.Cancel = func() error { return killGroup(proc.Process.Pid, syscall.SIGKILL) }
|
proc.Cancel = func() error { return killGroup(proc.Process.Pid, syscall.SIGKILL) }
|
||||||
|
|
||||||
stdout, err := proc.StdoutPipe()
|
out, err := s.openOutput(sh.ID, c.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
proc.Stderr = proc.Stdout.(io.Writer)
|
proc.Stdout, proc.Stderr = out, out
|
||||||
|
err = proc.Start()
|
||||||
if err := proc.Start(); err != nil {
|
out.Close()
|
||||||
|
if err != nil {
|
||||||
return nil, fmt.Errorf("starting %s: %w", c.Path, err)
|
return nil, fmt.Errorf("starting %s: %w", c.Path, err)
|
||||||
}
|
}
|
||||||
go s.pump(sh, stdout, c.Name)
|
sh.outDone.Add(1)
|
||||||
|
go s.follow(sh, s.outputPath(sh.ID, c.Name), c.Name)
|
||||||
// Reaped so it does not become a zombie; its exit does not end the shard.
|
// Reaped so it does not become a zombie; its exit does not end the shard.
|
||||||
go func() { _ = proc.Wait() }()
|
go func() { _ = proc.Wait() }()
|
||||||
return proc, nil
|
return proc, nil
|
||||||
@@ -395,24 +404,6 @@ func waitForPort(ctx context.Context, addr string, timeout time.Duration) (err e
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pump copies a process's output into the ring buffer and the log.
|
// pump copies a process's output into the ring buffer and the log.
|
||||||
func (s *Supervisor) pump(sh *Shard, r io.Reader, name string) {
|
|
||||||
sc := bufio.NewScanner(r)
|
|
||||||
// Engine tracebacks and progress bars produce long lines; the default
|
|
||||||
// 64 KiB limit truncates them exactly when they matter.
|
|
||||||
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
|
|
||||||
|
|
||||||
for sc.Scan() {
|
|
||||||
line := sc.Text()
|
|
||||||
if name != "" {
|
|
||||||
// Prefixed so the two processes of a Ray shard stay distinguishable
|
|
||||||
// in a single log tail.
|
|
||||||
line = "[" + name + "] " + line
|
|
||||||
}
|
|
||||||
sh.logs.add(line)
|
|
||||||
s.log.Debug("engine", "shard", sh.ID, "line", line)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wait reaps the process and records why it ended.
|
// wait reaps the process and records why it ended.
|
||||||
func (s *Supervisor) wait(sh *Shard) {
|
func (s *Supervisor) wait(sh *Shard) {
|
||||||
defer close(sh.done)
|
defer close(sh.done)
|
||||||
@@ -440,6 +431,7 @@ func (s *Supervisor) wait(sh *Shard) {
|
|||||||
final, code := sh.state, sh.exitCode
|
final, code := sh.state, sh.exitCode
|
||||||
sh.mu.Unlock()
|
sh.mu.Unlock()
|
||||||
|
|
||||||
|
s.stopOutput(sh)
|
||||||
s.removeState(sh)
|
s.removeState(sh)
|
||||||
if final == Failed {
|
if final == Failed {
|
||||||
s.log.Error("shard failed", "shard", sh.ID, "exit_code", code, "err", err,
|
s.log.Error("shard failed", "shard", sh.ID, "exit_code", code, "err", err,
|
||||||
@@ -760,6 +752,7 @@ func (s *Supervisor) writeState(sh *Shard) error {
|
|||||||
|
|
||||||
func (s *Supervisor) removeState(sh *Shard) {
|
func (s *Supervisor) removeState(sh *Shard) {
|
||||||
_ = os.Remove(s.statePath(sh.ID))
|
_ = os.Remove(s.statePath(sh.ID))
|
||||||
|
s.removeOutput(sh.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Orphan is an engine process left behind by a previous agent.
|
// Orphan is an engine process left behind by a previous agent.
|
||||||
@@ -838,15 +831,18 @@ func (s *Supervisor) Adopt(o Orphan) (*Shard, error) {
|
|||||||
logs: newRingBuffer(s.cfg.LogLines),
|
logs: newRingBuffer(s.cfg.LogLines),
|
||||||
started: o.StartedAt,
|
started: o.StartedAt,
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
outStop: make(chan struct{}),
|
||||||
adopted: true,
|
adopted: true,
|
||||||
cmdline: o.Cmdline,
|
cmdline: o.Cmdline,
|
||||||
}
|
}
|
||||||
s.shards[o.ShardID] = sh
|
s.shards[o.ShardID] = sh
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
|
|
||||||
// Output is gone with the previous agent; say so rather than presenting an
|
// Its output file is read from the start: what it wrote under the
|
||||||
// empty log as if the engine had been silent.
|
// previous agent is there, and what it writes now goes on arriving.
|
||||||
sh.logs.add("[adopted from a previous agent; earlier output is not available]")
|
sh.logs.add("[adopted from a previous agent]")
|
||||||
|
sh.outDone.Add(1)
|
||||||
|
go s.follow(sh, s.outputPath(sh.ID, ""), "")
|
||||||
|
|
||||||
go s.poll(sh)
|
go s.poll(sh)
|
||||||
return sh, nil
|
return sh, nil
|
||||||
@@ -874,6 +870,7 @@ func (s *Supervisor) poll(sh *Shard) {
|
|||||||
}
|
}
|
||||||
sh.mu.Unlock()
|
sh.mu.Unlock()
|
||||||
|
|
||||||
|
s.stopOutput(sh)
|
||||||
s.removeState(sh)
|
s.removeState(sh)
|
||||||
s.log.Info("adopted shard exited", "shard", sh.ID, "expected", expected)
|
s.log.Info("adopted shard exited", "shard", sh.ID, "expected", expected)
|
||||||
return
|
return
|
||||||
@@ -900,6 +897,7 @@ func (s *Supervisor) KillOrphan(o Orphan) error {
|
|||||||
}
|
}
|
||||||
// An already-missing state file is success: the previous agent may have
|
// An already-missing state file is success: the previous agent may have
|
||||||
// reaped the process itself between the scan and this call.
|
// reaped the process itself between the scan and this call.
|
||||||
|
s.removeOutput(o.ShardID)
|
||||||
if err := os.Remove(s.statePath(o.ShardID)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err := os.Remove(s.statePath(o.ShardID)); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package supervisor
|
package supervisor
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -905,3 +906,78 @@ func TestAnAdoptedShardsComputeIsKeptWithoutCrashing(t *testing.T) {
|
|||||||
t.Fatalf("lost: %+v", again)
|
t.Fatalf("lost: %+v", again)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnAdoptedEngineGoesOnWritingAndItsEarlierOutputIsKept(t *testing.T) {
|
||||||
|
// An engine's output went to a pipe of the agent's. The agent that
|
||||||
|
// restarted and adopted the engine left that pipe with no reader, and
|
||||||
|
// the engine's next write past the pipe's buffer failed with EPIPE —
|
||||||
|
// which, in a Python engine, raised inside whatever was being reported
|
||||||
|
// on and failed the request. Output goes to a file now: a reader is not
|
||||||
|
// something a file can lose, and the lines from before the restart are
|
||||||
|
// there for the agent after it.
|
||||||
|
s, _ := newSupervisor(t)
|
||||||
|
// 256 KiB at once outgrows a pipe's buffer; a reader gone means EPIPE.
|
||||||
|
sh, err := s.Start(t.Context(), "a", engines.ShardSpec{ReplicaID: "r"},
|
||||||
|
shellCommand(`echo before-restart; sleep 1; head -c 262144 /dev/zero | tr '\0' x; echo; echo after-restart-done; sleep 30`))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eventually(t, 3*time.Second, "the first line was never captured", func() bool {
|
||||||
|
return strings.Contains(strings.Join(sh.Logs(10), "\n"), "before-restart")
|
||||||
|
})
|
||||||
|
// The agent goes away without stopping the engine: nothing reads the
|
||||||
|
// output between here and the adoption.
|
||||||
|
pid := sh.Status().PID
|
||||||
|
sh.outOnce.Do(func() { close(sh.outStop) })
|
||||||
|
sh.outDone.Wait()
|
||||||
|
|
||||||
|
s2, err := New(Config{WorkDir: s.cfg.WorkDir, StopTimeout: 3 * time.Second, LogLines: 64},
|
||||||
|
&scriptedHealth{}, slog.New(slog.DiscardHandler))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
orphans, _ := s2.FindOrphans()
|
||||||
|
adopted, err := s2.Adopt(orphans[0])
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
eventually(t, 5*time.Second, "the engine's output after the restart never arrived: "+strings.Join(adopted.Logs(5), " | "), func() bool {
|
||||||
|
return strings.Contains(strings.Join(adopted.Logs(64), "\n"), "after-restart-done")
|
||||||
|
})
|
||||||
|
if !strings.Contains(strings.Join(adopted.Logs(64), "\n"), "before-restart") {
|
||||||
|
t.Error("the output from before the restart is not in the adopted log")
|
||||||
|
}
|
||||||
|
if !processAlive(pid) {
|
||||||
|
t.Fatal("the engine died writing output nobody was reading")
|
||||||
|
}
|
||||||
|
if err := s2.Stop(t.Context(), "a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(s2.outputPath("a", "")); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Errorf("the output file was not removed with the shard: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnOutputFileIsEmptiedPastItsBound(t *testing.T) {
|
||||||
|
s, _ := newSupervisor(t)
|
||||||
|
path := s.outputPath("a", "")
|
||||||
|
// A file already past the bound, as a long-running engine leaves one.
|
||||||
|
big, _ := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o644)
|
||||||
|
_, _ = big.Write(bytes.Repeat([]byte("x"), maxOutputBytes+1))
|
||||||
|
big.Close()
|
||||||
|
sh := &Shard{ID: "a", logs: newRingBuffer(8), done: make(chan struct{}), outStop: make(chan struct{})}
|
||||||
|
sh.outDone.Add(1)
|
||||||
|
go s.follow(sh, path, "")
|
||||||
|
time.Sleep(300 * time.Millisecond)
|
||||||
|
f, _ := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
|
_, _ = f.WriteString("still here\n")
|
||||||
|
f.Close()
|
||||||
|
s.stopOutput(sh)
|
||||||
|
st, _ := os.Stat(path)
|
||||||
|
if st.Size() > 1024 {
|
||||||
|
t.Errorf("the file was not emptied: %d bytes", st.Size())
|
||||||
|
}
|
||||||
|
if !strings.Contains(strings.Join(sh.Logs(8), "\n"), "still here") {
|
||||||
|
t.Errorf("a line written after the file was emptied was lost: %v", sh.Logs(8))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,574 @@
|
|||||||
|
// Package train runs LoRA training jobs on a node (BACKLOG F4): with an
|
||||||
|
// installed engine's Python — its torch and transformers, already matched to
|
||||||
|
// the node's CUDA — and a small overlay of what training needs besides, so a
|
||||||
|
// node that serves can train without a second multi-gigabyte environment.
|
||||||
|
package train
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bufio"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
_ "embed"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/supervisor"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed trainer.py
|
||||||
|
var trainerPy []byte
|
||||||
|
|
||||||
|
// overlayPackages are what training needs besides an engine's Python, pinned
|
||||||
|
// and installed without their dependencies: those are the engine's own, and
|
||||||
|
// letting an installer resolve them pulled a second torch.
|
||||||
|
var overlayPackages = []string{"peft==0.21.2", "accelerate==1.15.0"}
|
||||||
|
|
||||||
|
// Job is one training job, as the control plane sent it.
|
||||||
|
type Job struct {
|
||||||
|
ID string
|
||||||
|
BaseRepo string
|
||||||
|
BaseRevision string
|
||||||
|
// Data is NDJSON of {"q", "a"}.
|
||||||
|
Data []byte
|
||||||
|
// Params is the trainer's settings as JSON.
|
||||||
|
Params string
|
||||||
|
GPUMemoryMiB int
|
||||||
|
GGUF bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is how a job is going.
|
||||||
|
type Status struct {
|
||||||
|
State string
|
||||||
|
Message string
|
||||||
|
Step int
|
||||||
|
Steps int
|
||||||
|
Loss float64
|
||||||
|
PeakMiB int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Env is where a node keeps what training needs.
|
||||||
|
type Env struct {
|
||||||
|
// EnginesDir is where engines are installed (the agent's engines_dir).
|
||||||
|
EnginesDir string
|
||||||
|
// WorkDir is the trainer's own directory: its overlay, the llama.cpp
|
||||||
|
// conversion code, and each job's files while it runs.
|
||||||
|
WorkDir string
|
||||||
|
// RunAs is the user jobs run as: the engines' user.
|
||||||
|
RunAs string
|
||||||
|
Log *slog.Logger
|
||||||
|
// Python, UV, ConvDir and GPUs replace what would be found, for tests.
|
||||||
|
Python, UV, ConvDir string
|
||||||
|
GPUs func(ctx context.Context) ([]GPU, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GPU is one card and how much of it is free.
|
||||||
|
type GPU struct {
|
||||||
|
Index int
|
||||||
|
FreeMiB int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Runner runs one job at a time.
|
||||||
|
type Runner struct {
|
||||||
|
env Env
|
||||||
|
mu sync.Mutex
|
||||||
|
cancel context.CancelFunc
|
||||||
|
job string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New is a runner.
|
||||||
|
func New(env Env) *Runner {
|
||||||
|
if env.Log == nil {
|
||||||
|
env.Log = slog.New(slog.DiscardHandler)
|
||||||
|
}
|
||||||
|
return &Runner{env: env}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel stops a job if it is the one running.
|
||||||
|
func (r *Runner) Cancel(id string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.job == id && r.cancel != nil {
|
||||||
|
r.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrBusy is a node already training.
|
||||||
|
var ErrBusy = errors.New("this node is already training")
|
||||||
|
|
||||||
|
// Run runs a job to its end, telling status as it goes and artifact each
|
||||||
|
// piece of what it made. It answers the error it failed with.
|
||||||
|
func (r *Runner) Run(ctx context.Context, job Job, status func(Status), artifact func(kind string, seq int, data []byte, last bool) error) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
if r.job != "" {
|
||||||
|
r.mu.Unlock()
|
||||||
|
return ErrBusy
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
r.job, r.cancel = job.ID, cancel
|
||||||
|
r.mu.Unlock()
|
||||||
|
defer func() {
|
||||||
|
cancel()
|
||||||
|
r.mu.Lock()
|
||||||
|
r.job, r.cancel = "", nil
|
||||||
|
r.mu.Unlock()
|
||||||
|
}()
|
||||||
|
|
||||||
|
status(Status{State: "preparing", Message: "preparing the trainer"})
|
||||||
|
py, err := r.python()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
overlay, err := r.overlay(ctx, py)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("installing %s: %w", strings.Join(overlayPackages, ", "), err)
|
||||||
|
}
|
||||||
|
conv := ""
|
||||||
|
if job.GGUF {
|
||||||
|
if conv, err = r.conversion(ctx); err != nil {
|
||||||
|
return fmt.Errorf("fetching llama.cpp's conversion code: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
gpu, err := r.pickGPU(ctx, job.GPUMemoryMiB)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir := filepath.Join(r.env.WorkDir, "jobs", job.ID)
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
out := filepath.Join(dir, "adapter")
|
||||||
|
var params map[string]any
|
||||||
|
if job.Params != "" {
|
||||||
|
if err := json.Unmarshal([]byte(job.Params), ¶ms); err != nil {
|
||||||
|
return fmt.Errorf("the job's params: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
spec, _ := json.Marshal(map[string]any{"base_repo": job.BaseRepo, "base_revision": job.BaseRevision,
|
||||||
|
"data": filepath.Join(dir, "data.jsonl"), "out": out, "params": params})
|
||||||
|
for name, b := range map[string][]byte{"data.jsonl": job.Data, "job.json": spec, "trainer.py": trainerPy} {
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, name), b, 0o644); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var baseDir string
|
||||||
|
err = r.python3(ctx, py, dir, []string{"PYTHONPATH=" + overlay, "CUDA_VISIBLE_DEVICES=" + strconv.Itoa(gpu)},
|
||||||
|
[]string{filepath.Join(dir, "trainer.py"), filepath.Join(dir, "job.json")},
|
||||||
|
func(line map[string]any) {
|
||||||
|
s := Status{State: str(line["state"]), Message: str(line["message"]), Step: num(line["step"]),
|
||||||
|
Steps: num(line["steps"]), PeakMiB: num(line["peak_mib"])}
|
||||||
|
if v, ok := line["loss"].(float64); ok {
|
||||||
|
s.Loss = v
|
||||||
|
}
|
||||||
|
if b := str(line["base_dir"]); b != "" {
|
||||||
|
baseDir = b
|
||||||
|
}
|
||||||
|
if s.State == "trained" {
|
||||||
|
s.State = "running"
|
||||||
|
s.Message = "trained; packing the adapter"
|
||||||
|
}
|
||||||
|
status(s)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if job.GGUF {
|
||||||
|
status(Status{State: "running", Message: "converting the adapter to GGUF for llama.cpp"})
|
||||||
|
gguf := filepath.Join(dir, "adapter.gguf")
|
||||||
|
err := r.python3(ctx, py, dir, []string{"PYTHONPATH=" + strings.Join([]string{filepath.Join(conv, "gguf-py"), conv, overlay}, ":")},
|
||||||
|
[]string{filepath.Join(conv, "convert_lora_to_gguf.py"), "--base", baseDir, "--outtype", "f16", "--outfile", gguf, out}, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("converting to GGUF: %w", err)
|
||||||
|
}
|
||||||
|
if err := sendFile(gguf, "gguf", artifact); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var tgz bytes.Buffer
|
||||||
|
if err := tarGz(&tgz, out); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sendBytes(tgz.Bytes(), "peft", artifact)
|
||||||
|
}
|
||||||
|
|
||||||
|
func str(v any) string {
|
||||||
|
s, _ := v.(string)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func num(v any) int {
|
||||||
|
f, _ := v.(float64)
|
||||||
|
return int(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// python3 runs a Python script as the engines' user, passing each JSON line
|
||||||
|
// it prints to line, and answers the last of what it wrote to stderr when it
|
||||||
|
// fails.
|
||||||
|
func (r *Runner) python3(ctx context.Context, py, dir string, env, args []string, line func(map[string]any)) error {
|
||||||
|
cmd := exec.CommandContext(ctx, py, args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = append(os.Environ(), "PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True", "TOKENIZERS_PARALLELISM=false",
|
||||||
|
"HF_HUB_DISABLE_PROGRESS_BARS=1", "PYTHONUNBUFFERED=1")
|
||||||
|
cmd.Env = append(cmd.Env, env...)
|
||||||
|
home, give, why := supervisor.AsEngineUser(cmd, r.env.RunAs, r.env.WorkDir)
|
||||||
|
if why != "" {
|
||||||
|
r.env.Log.Warn(why)
|
||||||
|
}
|
||||||
|
if home != "" {
|
||||||
|
cmd.Env = append(cmd.Env, "HF_HOME="+filepath.Join(home, ".cache", "huggingface"))
|
||||||
|
} else {
|
||||||
|
cmd.Env = append(cmd.Env, "HF_HOME="+filepath.Join(r.env.WorkDir, "hf"))
|
||||||
|
}
|
||||||
|
if err := give(dir); err != nil {
|
||||||
|
return fmt.Errorf("handing %s to %s: %w", dir, r.env.RunAs, err)
|
||||||
|
}
|
||||||
|
// The whole process group goes when the job is cancelled: torch starts
|
||||||
|
// workers of its own.
|
||||||
|
if cmd.SysProcAttr == nil {
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
}
|
||||||
|
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var stderr tailBuffer
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
sc := bufio.NewScanner(stdout)
|
||||||
|
sc.Buffer(make([]byte, 64<<10), 1<<20)
|
||||||
|
for sc.Scan() {
|
||||||
|
var m map[string]any
|
||||||
|
if line != nil && json.Unmarshal(sc.Bytes(), &m) == nil {
|
||||||
|
line(m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
err = cmd.Wait()
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return errors.New("cancelled")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%v: %s", err, stderr.last())
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// tailBuffer keeps the end of what a process wrote: the reason it failed is
|
||||||
|
// at the end, and the rest is progress bars.
|
||||||
|
type tailBuffer struct{ b []byte }
|
||||||
|
|
||||||
|
func (t *tailBuffer) Write(p []byte) (int, error) {
|
||||||
|
t.b = append(t.b, p...)
|
||||||
|
if len(t.b) > 16<<10 {
|
||||||
|
t.b = t.b[len(t.b)-16<<10:]
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (t *tailBuffer) last() string {
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(t.b)), "\n")
|
||||||
|
if len(lines) > 3 {
|
||||||
|
lines = lines[len(lines)-3:]
|
||||||
|
}
|
||||||
|
return strings.Join(lines, " | ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// python is the newest installed vLLM's interpreter: its torch is built for
|
||||||
|
// this node's CUDA.
|
||||||
|
func (r *Runner) python() (string, error) {
|
||||||
|
if r.env.Python != "" {
|
||||||
|
return r.env.Python, nil
|
||||||
|
}
|
||||||
|
found, _ := filepath.Glob(filepath.Join(r.env.EnginesDir, "vllm", "*", "bin", "python3"))
|
||||||
|
if len(found) == 0 {
|
||||||
|
return "", errors.New("training needs vLLM installed on this node (its torch is what trains); " +
|
||||||
|
"llmgw ctl engine-build, or serving any vLLM model here, installs it")
|
||||||
|
}
|
||||||
|
sort.Strings(found)
|
||||||
|
return found[len(found)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Runner) uv() (string, error) {
|
||||||
|
if r.env.UV != "" {
|
||||||
|
return r.env.UV, nil
|
||||||
|
}
|
||||||
|
var found []string
|
||||||
|
_ = filepath.WalkDir(filepath.Join(r.env.EnginesDir, "uv"), func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err == nil && !d.IsDir() && d.Name() == "uv" {
|
||||||
|
found = append(found, p)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if len(found) == 0 {
|
||||||
|
return "", errors.New("no uv under " + filepath.Join(r.env.EnginesDir, "uv"))
|
||||||
|
}
|
||||||
|
sort.Strings(found)
|
||||||
|
return found[len(found)-1], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// overlay installs the pinned packages once per Python and set of pins.
|
||||||
|
func (r *Runner) overlay(ctx context.Context, py string) (string, error) {
|
||||||
|
sum := sha256.Sum256([]byte(py + "\x00" + strings.Join(overlayPackages, " ")))
|
||||||
|
dir := filepath.Join(r.env.WorkDir, "overlay-"+hex.EncodeToString(sum[:6]))
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, ".done")); err == nil {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
uv, err := r.uv()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
tmp := dir + ".new"
|
||||||
|
_ = os.RemoveAll(tmp)
|
||||||
|
args := append([]string{"pip", "install", "--quiet", "--python", py, "--no-deps", "--target", tmp}, overlayPackages...)
|
||||||
|
cmd := exec.CommandContext(ctx, uv, args...)
|
||||||
|
if out, err := cmd.CombinedOutput(); err != nil {
|
||||||
|
return "", fmt.Errorf("%v: %s", err, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
_ = os.WriteFile(filepath.Join(tmp, ".done"), nil, 0o644)
|
||||||
|
_ = os.RemoveAll(dir)
|
||||||
|
if err := os.Rename(tmp, dir); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Readable by the engines' user, which runs the trainer.
|
||||||
|
_ = filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err == nil {
|
||||||
|
mode := os.FileMode(0o644)
|
||||||
|
if d.IsDir() {
|
||||||
|
mode = 0o755
|
||||||
|
}
|
||||||
|
_ = os.Chmod(p, mode)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// llamaCppTag is the release of the newest llama.cpp installed: its
|
||||||
|
// conversion code is the one whose GGUF that llama.cpp reads.
|
||||||
|
func (r *Runner) llamaCppTag() (string, error) {
|
||||||
|
found, _ := filepath.Glob(filepath.Join(r.env.EnginesDir, "llamacpp", "b*"))
|
||||||
|
if len(found) == 0 {
|
||||||
|
return "", errors.New("a GGUF adapter needs llama.cpp installed on this node, to match its release")
|
||||||
|
}
|
||||||
|
sort.Strings(found)
|
||||||
|
tag, _, _ := strings.Cut(filepath.Base(found[len(found)-1]), "-")
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// conversionSource is where llama.cpp's source is fetched from, by tag.
|
||||||
|
var conversionSource = "https://codeload.github.com/ggml-org/llama.cpp/tar.gz/refs/tags/"
|
||||||
|
|
||||||
|
// conversion fetches llama.cpp's LoRA conversion code for the installed
|
||||||
|
// release, once.
|
||||||
|
func (r *Runner) conversion(ctx context.Context) (string, error) {
|
||||||
|
if r.env.ConvDir != "" {
|
||||||
|
return r.env.ConvDir, nil
|
||||||
|
}
|
||||||
|
tag, err := r.llamaCppTag()
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir := filepath.Join(r.env.WorkDir, "llama.cpp-"+tag)
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "convert_lora_to_gguf.py")); err == nil {
|
||||||
|
return dir, nil
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, conversionSource+tag, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
if res.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("%s answered %s", conversionSource+tag, res.Status)
|
||||||
|
}
|
||||||
|
tmp := dir + ".new"
|
||||||
|
_ = os.RemoveAll(tmp)
|
||||||
|
if err := untarConversion(res.Body, tmp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
_ = os.RemoveAll(dir)
|
||||||
|
return dir, os.Rename(tmp, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// untarConversion keeps, of llama.cpp's source, the conversion scripts and
|
||||||
|
// the Python packages they import.
|
||||||
|
func untarConversion(r io.Reader, dir string) error {
|
||||||
|
gz, err := gzip.NewReader(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
kept := 0
|
||||||
|
for {
|
||||||
|
h, err := tr.Next()
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, rel, ok := strings.Cut(h.Name, "/")
|
||||||
|
if !ok || h.Typeflag != tar.TypeReg || strings.Contains(rel, "..") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rel != "convert_lora_to_gguf.py" && rel != "convert_hf_to_gguf.py" &&
|
||||||
|
!strings.HasPrefix(rel, "conversion/") && !strings.HasPrefix(rel, "gguf-py/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p := filepath.Join(dir, filepath.FromSlash(rel))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.OpenFile(p, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = io.Copy(f, io.LimitReader(tr, 64<<20))
|
||||||
|
f.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
kept++
|
||||||
|
}
|
||||||
|
if kept == 0 {
|
||||||
|
return errors.New("no conversion code in llama.cpp's source")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickGPU is the card with the most memory free, if that is enough.
|
||||||
|
func (r *Runner) pickGPU(ctx context.Context, need int) (int, error) {
|
||||||
|
list := r.env.GPUs
|
||||||
|
if list == nil {
|
||||||
|
list = nvidiaSMI
|
||||||
|
}
|
||||||
|
gpus, err := list(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("reading the cards' free memory: %w", err)
|
||||||
|
}
|
||||||
|
if len(gpus) == 0 {
|
||||||
|
return 0, errors.New("this node has no GPU to train on")
|
||||||
|
}
|
||||||
|
best := gpus[0]
|
||||||
|
for _, g := range gpus[1:] {
|
||||||
|
if g.FreeMiB > best.FreeMiB {
|
||||||
|
best = g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if best.FreeMiB < need {
|
||||||
|
return 0, fmt.Errorf("no card here has %d MiB free (the most free is GPU %d with %d MiB); "+
|
||||||
|
"a smaller base, or a card freed for it, would do", need, best.Index, best.FreeMiB)
|
||||||
|
}
|
||||||
|
return best.Index, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func nvidiaSMI(ctx context.Context) ([]GPU, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
out, err := exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=index,memory.free", "--format=csv,noheader,nounits").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseGPUs(string(out)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseGPUs(s string) []GPU {
|
||||||
|
var out []GPU
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(s), "\n") {
|
||||||
|
a, b, ok := strings.Cut(line, ",")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
i, err1 := strconv.Atoi(strings.TrimSpace(a))
|
||||||
|
f, err2 := strconv.Atoi(strings.TrimSpace(b))
|
||||||
|
if err1 == nil && err2 == nil {
|
||||||
|
out = append(out, GPU{Index: i, FreeMiB: f})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// tarGz packs a directory.
|
||||||
|
func tarGz(w io.Writer, dir string) error {
|
||||||
|
gz := gzip.NewWriter(w)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
|
||||||
|
if err != nil || d.IsDir() {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rel, _ := filepath.Rel(dir, p)
|
||||||
|
info, err := d.Info()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tw.WriteHeader(&tar.Header{Name: filepath.ToSlash(rel), Mode: 0o644, Size: info.Size(), ModTime: info.ModTime()}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
f, err := os.Open(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
_, err = io.Copy(tw, f)
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return gz.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// artifactChunk is how much of an artifact goes in one message.
|
||||||
|
const artifactChunk = 1 << 20
|
||||||
|
|
||||||
|
func sendFile(path, kind string, send func(string, int, []byte, bool) error) error {
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sendBytes(b, kind, send)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendBytes(b []byte, kind string, send func(string, int, []byte, bool) error) error {
|
||||||
|
seq := 0
|
||||||
|
for {
|
||||||
|
n := min(artifactChunk, len(b))
|
||||||
|
last := n == len(b)
|
||||||
|
if err := send(kind, seq, b[:n], last); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if last {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b = b[n:]
|
||||||
|
seq++
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
package train
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakePython stands in for an engine's interpreter: as the trainer it
|
||||||
|
// prints progress and writes an adapter; as the converter it writes a GGUF;
|
||||||
|
// told to fail, it fails.
|
||||||
|
const fakePython = `#!/bin/sh
|
||||||
|
case "$1" in
|
||||||
|
*trainer.py)
|
||||||
|
out=$(sed -n 's/.*"out": *"\([^"]*\)".*/\1/p' "$2")
|
||||||
|
grep -q FAIL "$(dirname "$2")/data.jsonl" && { echo "CUDA out of memory" >&2; exit 1; }
|
||||||
|
echo '{"state":"preparing","message":"fetching base"}'
|
||||||
|
echo '{"state":"running","step":5,"steps":10,"loss":0.9,"peak_mib":1900}'
|
||||||
|
mkdir -p "$out"; echo '{"r":16}' > "$out/adapter_config.json"; echo weights > "$out/adapter_model.safetensors"
|
||||||
|
echo '{"state":"trained","step":10,"steps":10,"peak_mib":2100,"base_dir":"/base"}'
|
||||||
|
;;
|
||||||
|
*convert_lora_to_gguf.py)
|
||||||
|
while [ $# -gt 0 ]; do [ "$1" = "--outfile" ] && echo GGUF > "$2"; shift; done
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
`
|
||||||
|
|
||||||
|
func runner(t *testing.T, free int) *Runner {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
py := filepath.Join(dir, "python3")
|
||||||
|
_ = os.WriteFile(py, []byte(fakePython), 0o755)
|
||||||
|
uv := filepath.Join(dir, "uv")
|
||||||
|
_ = os.WriteFile(uv, []byte("#!/bin/sh\nwhile [ $# -gt 0 ]; do [ \"$1\" = \"--target\" ] && mkdir -p \"$2\"; shift; done\n"), 0o755)
|
||||||
|
return New(Env{WorkDir: filepath.Join(dir, "work"), Python: py, UV: uv, ConvDir: dir,
|
||||||
|
GPUs: func(context.Context) ([]GPU, error) { return []GPU{{0, 500}, {1, free}}, nil }})
|
||||||
|
}
|
||||||
|
|
||||||
|
type sink struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
statuses []Status
|
||||||
|
parts map[string][]byte
|
||||||
|
lasts int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *sink) status(st Status) { s.mu.Lock(); s.statuses = append(s.statuses, st); s.mu.Unlock() }
|
||||||
|
func (s *sink) artifact(kind string, _ int, b []byte, last bool) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
if s.parts == nil {
|
||||||
|
s.parts = map[string][]byte{}
|
||||||
|
}
|
||||||
|
s.parts[kind] = append(s.parts[kind], b...)
|
||||||
|
if last {
|
||||||
|
s.lasts++
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAJobTrainsOnTheFreestCardAndSendsWhatItMade(t *testing.T) {
|
||||||
|
r := runner(t, 3000)
|
||||||
|
s := &sink{}
|
||||||
|
err := r.Run(context.Background(), Job{ID: "j1", BaseRepo: "Qwen/x", Data: []byte(`{"q":"a","a":"b"}`), GPUMemoryMiB: 2500, GGUF: true},
|
||||||
|
s.status, s.artifact)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var running bool
|
||||||
|
for _, st := range s.statuses {
|
||||||
|
if st.State == "running" && st.Step == 5 && st.Steps == 10 && st.PeakMiB == 1900 {
|
||||||
|
running = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !running {
|
||||||
|
t.Errorf("statuses %+v", s.statuses)
|
||||||
|
}
|
||||||
|
if string(s.parts["gguf"]) != "GGUF\n" || s.lasts != 2 {
|
||||||
|
t.Errorf("gguf %q, lasts %d", s.parts["gguf"], s.lasts)
|
||||||
|
}
|
||||||
|
files := map[string]string{}
|
||||||
|
gz, err := gzip.NewReader(bytes.NewReader(s.parts["peft"]))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(gz)
|
||||||
|
for {
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
b, _ := io.ReadAll(tr)
|
||||||
|
files[h.Name] = string(b)
|
||||||
|
}
|
||||||
|
if files["adapter_model.safetensors"] != "weights\n" || files["adapter_config.json"] == "" {
|
||||||
|
t.Errorf("peft archive %v", files)
|
||||||
|
}
|
||||||
|
// Nothing of the job is left on the node.
|
||||||
|
if entries, _ := os.ReadDir(filepath.Join(r.env.WorkDir, "jobs")); len(entries) != 0 {
|
||||||
|
t.Errorf("left behind: %v", entries)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAJobWaitsForNoCardItDoesNotFit(t *testing.T) {
|
||||||
|
r := runner(t, 1000)
|
||||||
|
err := r.Run(context.Background(), Job{ID: "j1", Data: []byte(`{"q":"a","a":"b"}`), GPUMemoryMiB: 2500}, (&sink{}).status, (&sink{}).artifact)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "no card here has 2500 MiB free") {
|
||||||
|
t.Errorf("got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestATrainerThatFailsSaysWhy(t *testing.T) {
|
||||||
|
r := runner(t, 3000)
|
||||||
|
err := r.Run(context.Background(), Job{ID: "j1", Data: []byte(`{"q":"FAIL","a":"b"}`)}, (&sink{}).status, (&sink{}).artifact)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "CUDA out of memory") {
|
||||||
|
t.Errorf("got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOneJobAtATime(t *testing.T) {
|
||||||
|
r := runner(t, 3000)
|
||||||
|
r.job = "other"
|
||||||
|
if err := r.Run(context.Background(), Job{ID: "j2"}, (&sink{}).status, (&sink{}).artifact); err != ErrBusy {
|
||||||
|
t.Errorf("got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCardsAreReadFromNvidiaSMI(t *testing.T) {
|
||||||
|
got := parseGPUs("0, 2310\n1, 80000\nbad line\n")
|
||||||
|
if len(got) != 2 || got[1].FreeMiB != 80000 || got[0].Index != 0 {
|
||||||
|
t.Errorf("parsed %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOnlyTheConversionCodeIsKeptOfLlamaCpp(t *testing.T) {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
gz := gzip.NewWriter(&buf)
|
||||||
|
tw := tar.NewWriter(gz)
|
||||||
|
for _, name := range []string{"llama.cpp-b1/convert_lora_to_gguf.py", "llama.cpp-b1/conversion/qwen.py",
|
||||||
|
"llama.cpp-b1/gguf-py/gguf/__init__.py", "llama.cpp-b1/src/llama.cpp", "llama.cpp-b1/../evil"} {
|
||||||
|
_ = tw.WriteHeader(&tar.Header{Name: name, Mode: 0o644, Size: 1, Typeflag: tar.TypeReg})
|
||||||
|
_, _ = tw.Write([]byte("x"))
|
||||||
|
}
|
||||||
|
_ = tw.Close()
|
||||||
|
_ = gz.Close()
|
||||||
|
dir := filepath.Join(t.TempDir(), "conv")
|
||||||
|
if err := untarConversion(&buf, dir); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"convert_lora_to_gguf.py", "conversion/qwen.py", "gguf-py/gguf/__init__.py"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, want)); err != nil {
|
||||||
|
t.Errorf("%s not kept", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "src")); err == nil {
|
||||||
|
t.Error("the rest of llama.cpp was kept")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# llmgw's LoRA trainer (BACKLOG F4), run by the node agent with an installed
|
||||||
|
# engine's Python (its torch and transformers) and a small overlay (peft,
|
||||||
|
# accelerate). Reads a job, trains, writes the PEFT adapter, and says how it
|
||||||
|
# goes as one JSON object a line on standard output.
|
||||||
|
import json, math, os, random, sys, threading, subprocess, time
|
||||||
|
|
||||||
|
def say(**kw):
|
||||||
|
print(json.dumps(kw), flush=True)
|
||||||
|
|
||||||
|
job = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||||
|
p = {"rank": 16, "alpha": 32, "dropout": 0.05, "epochs": 6, "lr": 2e-4, "batch": 4, "max_len": 512, "seed": 0}
|
||||||
|
p.update(job.get("params") or {})
|
||||||
|
|
||||||
|
say(state="preparing", message="loading libraries")
|
||||||
|
import torch
|
||||||
|
from huggingface_hub import snapshot_download
|
||||||
|
from transformers import AutoModelForCausalLM, AutoTokenizer
|
||||||
|
from peft import LoraConfig, get_peft_model
|
||||||
|
|
||||||
|
random.seed(p["seed"]); torch.manual_seed(p["seed"])
|
||||||
|
if not torch.cuda.is_available():
|
||||||
|
raise SystemExit("no CUDA device is visible to the trainer")
|
||||||
|
|
||||||
|
say(state="preparing", message="fetching " + job["base_repo"])
|
||||||
|
base_dir = snapshot_download(job["base_repo"], revision=job.get("base_revision") or None,
|
||||||
|
allow_patterns=["*.json", "*.safetensors", "*.txt", "*.model", "*.tiktoken"])
|
||||||
|
say(state="preparing", message="loading the base model", base_dir=base_dir)
|
||||||
|
|
||||||
|
# bf16 where the card has it; fp16 with a loss scaler where it does not
|
||||||
|
# (Turing and older).
|
||||||
|
bf16 = torch.cuda.is_bf16_supported()
|
||||||
|
dtype = torch.bfloat16 if bf16 else torch.float16
|
||||||
|
tok = AutoTokenizer.from_pretrained(base_dir)
|
||||||
|
if tok.pad_token_id is None:
|
||||||
|
tok.pad_token = tok.eos_token
|
||||||
|
model = AutoModelForCausalLM.from_pretrained(base_dir, dtype=dtype).to("cuda")
|
||||||
|
|
||||||
|
rows = [json.loads(l) for l in open(job["data"], encoding="utf-8") if l.strip()]
|
||||||
|
rows = [r for r in rows if r.get("q") and r.get("a")]
|
||||||
|
if not rows:
|
||||||
|
raise SystemExit("no {q, a} pairs in the data")
|
||||||
|
|
||||||
|
def encode(r):
|
||||||
|
prompt = tok.apply_chat_template([{"role": "user", "content": r["q"]}], add_generation_prompt=True, tokenize=False)
|
||||||
|
full = tok.apply_chat_template([{"role": "user", "content": r["q"]}, {"role": "assistant", "content": r["a"]}], tokenize=False)
|
||||||
|
pi = tok(prompt, add_special_tokens=False)["input_ids"]
|
||||||
|
fi = tok(full, add_special_tokens=False)["input_ids"][: p["max_len"]]
|
||||||
|
return fi, ([-100] * len(pi) + fi[len(pi):])[: len(fi)]
|
||||||
|
|
||||||
|
data = [encode(r) for r in rows]
|
||||||
|
|
||||||
|
model.gradient_checkpointing_enable()
|
||||||
|
model.enable_input_require_grads()
|
||||||
|
model = get_peft_model(model, LoraConfig(r=int(p["rank"]), lora_alpha=int(p["alpha"]), lora_dropout=float(p["dropout"]),
|
||||||
|
task_type="CAUSAL_LM", target_modules=["q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj"]))
|
||||||
|
trainable = [x for x in model.parameters() if x.requires_grad]
|
||||||
|
for x in trainable: # fp32 trainable weights, so a loss scaler can unscale them
|
||||||
|
x.data = x.data.float()
|
||||||
|
|
||||||
|
peak = {"mib": 0}
|
||||||
|
stop = threading.Event()
|
||||||
|
def watch():
|
||||||
|
while not stop.is_set():
|
||||||
|
try:
|
||||||
|
out = subprocess.run(["nvidia-smi", "--query-compute-apps=pid,used_memory", "--format=csv,noheader,nounits"],
|
||||||
|
capture_output=True, text=True, timeout=5).stdout
|
||||||
|
for line in out.splitlines():
|
||||||
|
pid, mib = [v.strip() for v in line.split(",")]
|
||||||
|
if int(pid) == os.getpid():
|
||||||
|
peak["mib"] = max(peak["mib"], int(mib))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.5)
|
||||||
|
threading.Thread(target=watch, daemon=True).start()
|
||||||
|
|
||||||
|
opt = torch.optim.AdamW(trainable, lr=float(p["lr"]), weight_decay=0.0)
|
||||||
|
scaler = torch.amp.GradScaler("cuda", enabled=not bf16)
|
||||||
|
bs, epochs = int(p["batch"]), int(p["epochs"])
|
||||||
|
steps = epochs * math.ceil(len(data) / bs)
|
||||||
|
warm = max(1, steps // 10)
|
||||||
|
sched = torch.optim.lr_scheduler.LambdaLR(opt, lambda s: min(1.0, (s + 1) / warm) * max(0.0, 1 - s / steps))
|
||||||
|
model.train(); step = 0; last = time.time()
|
||||||
|
for ep in range(epochs):
|
||||||
|
random.shuffle(data)
|
||||||
|
for i in range(0, len(data), bs):
|
||||||
|
batch = data[i:i + bs]; n = max(len(x[0]) for x in batch)
|
||||||
|
ids = torch.tensor([x[0] + [tok.pad_token_id] * (n - len(x[0])) for x in batch], device="cuda")
|
||||||
|
lab = torch.tensor([x[1] + [-100] * (n - len(x[1])) for x in batch], device="cuda")
|
||||||
|
att = torch.tensor([[1] * len(x[0]) + [0] * (n - len(x[0])) for x in batch], device="cuda")
|
||||||
|
with torch.autocast("cuda", dtype=dtype):
|
||||||
|
loss = model(input_ids=ids, attention_mask=att, labels=lab).loss
|
||||||
|
scaler.scale(loss).backward(); scaler.unscale_(opt)
|
||||||
|
torch.nn.utils.clip_grad_norm_(trainable, 1.0)
|
||||||
|
scaler.step(opt); scaler.update(); opt.zero_grad(set_to_none=True); sched.step(); step += 1
|
||||||
|
if time.time() - last > 2 or step == steps:
|
||||||
|
last = time.time()
|
||||||
|
say(state="running", message="training", step=step, steps=steps, loss=round(loss.item(), 4), peak_mib=peak["mib"])
|
||||||
|
|
||||||
|
model.save_pretrained(job["out"])
|
||||||
|
stop.set()
|
||||||
|
say(state="trained", step=step, steps=steps, peak_mib=peak["mib"], base_dir=base_dir)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/train"
|
||||||
|
)
|
||||||
|
|
||||||
|
// trainJob runs a training job the control plane sent, telling it how the
|
||||||
|
// job goes and sending what it made. A stream that ends ends the job: the
|
||||||
|
// control plane, which sees the node go, marks it failed, and it is started
|
||||||
|
// again from the beginning rather than resumed.
|
||||||
|
func (a *Agent) trainJob(ctx context.Context, m *llmgwv1.TrainStart, out chan<- *llmgwv1.AgentMessage) {
|
||||||
|
id := m.GetJobId()
|
||||||
|
send := func(msg *llmgwv1.AgentMessage) bool {
|
||||||
|
select {
|
||||||
|
case out <- msg:
|
||||||
|
return true
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
status := func(s train.Status) {
|
||||||
|
send(&llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_TrainStatus{TrainStatus: &llmgwv1.TrainStatus{
|
||||||
|
JobId: id, State: s.State, Message: s.Message, Step: int32(s.Step), Steps: int32(s.Steps), //nolint:gosec // counts
|
||||||
|
Loss: s.Loss, PeakMib: int32(s.PeakMiB)}}}) //nolint:gosec // MiB
|
||||||
|
}
|
||||||
|
artifact := func(kind string, seq int, data []byte, last bool) error {
|
||||||
|
if !send(&llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_TrainArtifact{TrainArtifact: &llmgwv1.TrainArtifact{
|
||||||
|
JobId: id, Kind: kind, Seq: int32(seq), Data: data, Last: last}}}) { //nolint:gosec // chunk count
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a.log.Info("training an adapter", "job", id, "base", m.GetBaseRepo())
|
||||||
|
err := a.trainer.Run(ctx, train.Job{ID: id, BaseRepo: m.GetBaseRepo(), BaseRevision: m.GetBaseRevision(),
|
||||||
|
Data: m.GetData(), Params: m.GetParams(), GPUMemoryMiB: int(m.GetGpuMemoryMib()), GGUF: m.GetGguf()}, status, artifact)
|
||||||
|
if err != nil {
|
||||||
|
a.log.Warn("training failed", "job", id, "err", err)
|
||||||
|
status(train.Status{State: "failed", Message: err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.log.Info("trained an adapter", "job", id)
|
||||||
|
status(train.Status{State: "done"})
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newAnswerCacheCmd(open opener) *cobra.Command {
|
func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||||
@@ -12,6 +14,7 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
|||||||
project string
|
project string
|
||||||
ttl time.Duration
|
ttl time.Duration
|
||||||
anyAnswer bool
|
anyAnswer bool
|
||||||
|
similar float64
|
||||||
off bool
|
off bool
|
||||||
)
|
)
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
@@ -28,15 +31,25 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
|||||||
"a request continuing a stored conversation or under a guardrails no_store\n" +
|
"a request continuing a stored conversation or under a guardrails no_store\n" +
|
||||||
"policy. The guardrails judge a reused answer as they judge any other.\n\n" +
|
"policy. The guardrails judge a reused answer as they judge any other.\n\n" +
|
||||||
"Off for every project until this is run. Without --ttl or --off, says how\n" +
|
"Off for every project until this is run. Without --ttl or --off, says how\n" +
|
||||||
"it stands. The answers are held in memory, router.cache bounds how much.",
|
"it stands. The answers are held in memory, router.cache bounds how much.\n\n" +
|
||||||
|
"--similar also answers a chat question that is like one already answered:\n" +
|
||||||
|
"its last user message embedded by router.cache.embedding_model, and given\n" +
|
||||||
|
"the answer whose question reaches that cosine similarity (0.95 is strict,\n" +
|
||||||
|
"0.9 looser), as x-llmgw-cache: similar. The system prompt, the earlier\n" +
|
||||||
|
"turns, tools and the sampling settings must still be the same.",
|
||||||
Example: " llmgw keys cache --project Acme/ml/train --ttl 1h\n" +
|
Example: " llmgw keys cache --project Acme/ml/train --ttl 1h\n" +
|
||||||
" llmgw keys cache --project Acme/ml/eval --ttl 24h --any\n" +
|
" llmgw keys cache --project Acme/ml/eval --ttl 24h --any\n" +
|
||||||
|
" llmgw keys cache --project Acme/support/desk --ttl 24h --similar 0.95\n" +
|
||||||
" llmgw keys cache --project Acme/ml/train --off",
|
" llmgw keys cache --project Acme/ml/train --off",
|
||||||
Args: cobra.NoArgs,
|
Args: cobra.NoArgs,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
switch {
|
switch {
|
||||||
case off && (ttl != 0 || anyAnswer):
|
case off && (ttl != 0 || anyAnswer || similar != 0):
|
||||||
return fmt.Errorf("--off, or --ttl with --any: not both")
|
return fmt.Errorf("--off, or --ttl with --any or --similar: not both")
|
||||||
|
case similar < 0 || similar > 1:
|
||||||
|
return fmt.Errorf("--similar is a cosine similarity between 0 and 1, like 0.95")
|
||||||
|
case similar != 0 && ttl == 0:
|
||||||
|
return fmt.Errorf("--similar goes with --ttl: say how long answers are reused")
|
||||||
case ttl < 0:
|
case ttl < 0:
|
||||||
return fmt.Errorf("--ttl is how long an answer is reused, like 1h")
|
return fmt.Errorf("--ttl is how long an answer is reused, like 1h")
|
||||||
case ttl > 0 && ttl < time.Second:
|
case ttl > 0 && ttl < time.Second:
|
||||||
@@ -54,7 +67,7 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if off || ttl > 0 {
|
if off || ttl > 0 {
|
||||||
if err := s.SetAnswerCache(cmd.Context(), p.ID, ttl, anyAnswer); err != nil {
|
if err := s.SetAnswerCache(cmd.Context(), p.ID, store.AnswerCache{TTL: ttl, Any: anyAnswer, Similar: similar}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -71,6 +84,9 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
|||||||
fmt.Fprintf(cmd.OutOrStdout(), "%s reuses answers for %s: those asked at temperature 0 or with a seed, "+
|
fmt.Fprintf(cmd.OutOrStdout(), "%s reuses answers for %s: those asked at temperature 0 or with a seed, "+
|
||||||
"and embeddings, rerank and score.\n", p.Path, c.TTL)
|
"and embeddings, rerank and score.\n", p.Path, c.TTL)
|
||||||
}
|
}
|
||||||
|
if c.TTL > 0 && c.Similar > 0 {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "Chat questions reaching %.2f similarity to one answered get its answer too.\n", c.Similar)
|
||||||
|
}
|
||||||
if off || ttl > 0 {
|
if off || ttl > 0 {
|
||||||
fmt.Fprintln(cmd.OutOrStdout(), "Gateways see the change within 30 seconds.")
|
fmt.Fprintln(cmd.OutOrStdout(), "Gateways see the change within 30 seconds.")
|
||||||
}
|
}
|
||||||
@@ -80,6 +96,7 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&project, "project", "", "project: id, path or unique name")
|
cmd.Flags().StringVar(&project, "project", "", "project: id, path or unique name")
|
||||||
cmd.Flags().DurationVar(&ttl, "ttl", 0, "how long an answer is reused, like 1h")
|
cmd.Flags().DurationVar(&ttl, "ttl", 0, "how long an answer is reused, like 1h")
|
||||||
cmd.Flags().BoolVar(&anyAnswer, "any", false, "reuse sampled answers too, not only reproducible ones")
|
cmd.Flags().BoolVar(&anyAnswer, "any", false, "reuse sampled answers too, not only reproducible ones")
|
||||||
|
cmd.Flags().Float64Var(&similar, "similar", 0, "also answer chat questions this similar to one answered, like 0.95")
|
||||||
cmd.Flags().BoolVar(&off, "off", false, "stop reusing answers for this project")
|
cmd.Flags().BoolVar(&off, "off", false, "stop reusing answers for this project")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
@@ -249,7 +249,14 @@ func pgEnv(dsn string) ([]string, error) {
|
|||||||
set("PGPASSWORD", pw)
|
set("PGPASSWORD", pw)
|
||||||
}
|
}
|
||||||
set("PGDATABASE", strings.TrimPrefix(u.Path, "/"))
|
set("PGDATABASE", strings.TrimPrefix(u.Path, "/"))
|
||||||
set("PGSSLMODE", u.Query().Get("sslmode"))
|
// The TLS settings go with the mode: verify-full without the cluster's
|
||||||
|
// authority sent libpq looking for ~/.postgresql/root.crt, and the dump
|
||||||
|
// failed on every control plane whose store is replicated.
|
||||||
|
q := u.Query()
|
||||||
|
set("PGSSLMODE", q.Get("sslmode"))
|
||||||
|
set("PGSSLROOTCERT", q.Get("sslrootcert"))
|
||||||
|
set("PGSSLCERT", q.Get("sslcert"))
|
||||||
|
set("PGSSLKEY", q.Get("sslkey"))
|
||||||
// A standby if there is one, else whichever answers — not the address's
|
// A standby if there is one, else whichever answers — not the address's
|
||||||
// read-write: a dump only reads, it need not load the primary, and one
|
// read-write: a dump only reads, it need not load the primary, and one
|
||||||
// that insists on the primary fails exactly while the cluster is failing
|
// that insists on the primary fails exactly while the cluster is failing
|
||||||
|
|||||||
@@ -238,3 +238,17 @@ func TestAStoreOfSeveralHostsIsGivenToPgDumpAsLibpqLists(t *testing.T) {
|
|||||||
t.Error("an empty host in the list was accepted")
|
t.Error("an empty host in the list was accepted")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPgDumpIsGivenTheClusterAuthorityAlongWithVerifyFull(t *testing.T) {
|
||||||
|
// Without it libpq looked for ~/.postgresql/root.crt and the dump failed.
|
||||||
|
env, err := pgEnv("postgres://llmgw:p@10.0.0.149:5432,10.0.0.150:5432/llmgw?sslmode=verify-full&sslrootcert=%2Fetc%2Fllmgw%2Fpki%2Fca.crt&target_session_attrs=read-write")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got := strings.Join(env, " ")
|
||||||
|
for _, want := range []string{"PGSSLMODE=verify-full", "PGSSLROOTCERT=/etc/llmgw/pki/ca.crt"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("%s is not in %s", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,6 +150,7 @@ func newBenchCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|||||||
f.DurationVar(&cfg.SLO.TPOTp99, "tpot-p99", cfg.SLO.TPOTp99, "per-output-token target")
|
f.DurationVar(&cfg.SLO.TPOTp99, "tpot-p99", cfg.SLO.TPOTp99, "per-output-token target")
|
||||||
f.BoolVar(&save, "save", false,
|
f.BoolVar(&save, "save", false,
|
||||||
"record the capacity against the model and profile, so the number outlives this terminal")
|
"record the capacity against the model and profile, so the number outlives this terminal")
|
||||||
|
cmd.AddCommand(newBenchOverheadCmd(asJSON))
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,421 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/router"
|
||||||
|
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What llmgw adds to a request, measured: the same load straight at an
|
||||||
|
// engine that answers at once, and through the gateway in front of it —
|
||||||
|
// bare, with authentication and usage accounting, and with the guardrails'
|
||||||
|
// pattern checks besides. The engine's own time is the same on both paths,
|
||||||
|
// so what differs is the gateway's.
|
||||||
|
//
|
||||||
|
// Everything runs in this process, on loopback: no cluster, no GPU, and the
|
||||||
|
// numbers are this machine's. On a machine shared with other work (a VM on
|
||||||
|
// an oversubscribed host) they move with it; compare modes run together,
|
||||||
|
// not figures from different runs.
|
||||||
|
|
||||||
|
// overheadMode is one way a request reaches the engine.
|
||||||
|
type overheadMode struct {
|
||||||
|
name string
|
||||||
|
gateway bool
|
||||||
|
auth, guard bool
|
||||||
|
}
|
||||||
|
|
||||||
|
var overheadModes = []overheadMode{
|
||||||
|
{name: "direct"},
|
||||||
|
{name: "gateway", gateway: true},
|
||||||
|
{name: "gateway+auth", gateway: true, auth: true},
|
||||||
|
{name: "gateway+auth+guard", gateway: true, auth: true, guard: true},
|
||||||
|
}
|
||||||
|
|
||||||
|
// overheadStep is one mode at one request rate.
|
||||||
|
type overheadStep struct {
|
||||||
|
Mode string `json:"mode"`
|
||||||
|
Rate int `json:"rate"`
|
||||||
|
Sent int `json:"sent"`
|
||||||
|
Failed int `json:"failed"`
|
||||||
|
P50 time.Duration `json:"p50"`
|
||||||
|
P99 time.Duration `json:"p99"`
|
||||||
|
AddedP50 time.Duration `json:"added_p50"`
|
||||||
|
AddedP99 time.Duration `json:"added_p99"`
|
||||||
|
// CPUPer1k is the process's CPU time per thousand requests beyond what
|
||||||
|
// the direct mode spent at the same rate: the gateway's.
|
||||||
|
CPUPer1k time.Duration `json:"cpu_per_1k"`
|
||||||
|
cpu time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func newBenchOverheadCmd(asJSON *bool) *cobra.Command {
|
||||||
|
var (
|
||||||
|
rates []int
|
||||||
|
each time.Duration
|
||||||
|
)
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "overhead",
|
||||||
|
Short: "Measure what the gateway adds to a request, against an engine that answers at once",
|
||||||
|
Long: "Runs an engine that answers at once and a gateway in front of it, in this\n" +
|
||||||
|
"process on loopback, and sends the same load at each rate straight to the\n" +
|
||||||
|
"engine and through the gateway: bare, with authentication and usage\n" +
|
||||||
|
"accounting, and with the guardrails' pattern checks too. Reports each\n" +
|
||||||
|
"request's latency at p50 and p99, what the gateway added to it, and the\n" +
|
||||||
|
"gateway's CPU time per thousand requests.\n\n" +
|
||||||
|
"Needs no cluster. The figures are this machine's: on one shared with other\n" +
|
||||||
|
"work they move with it, so compare the modes of one run, and note the\n" +
|
||||||
|
"machine's load beside any figure you keep.",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
steps, err := runOverhead(cmd.Context(), rates, each)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *asJSON {
|
||||||
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(steps)
|
||||||
|
}
|
||||||
|
printOverhead(cmd.OutOrStdout(), steps)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := cmd.Flags()
|
||||||
|
f.IntSliceVar(&rates, "rates", []int{100, 500, 1000, 2000}, "requests a second to send, one step each")
|
||||||
|
f.DurationVar(&each, "each", 3*time.Second, "how long each step sends for")
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func printOverhead(w io.Writer, steps []overheadStep) {
|
||||||
|
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
fmt.Fprintln(tw, "MODE\tRATE/S\tSENT\tFAILED\tP50\tP99\t+P50\t+P99\tCPU/1K REQ")
|
||||||
|
for _, s := range steps {
|
||||||
|
added := func(d time.Duration) string {
|
||||||
|
if s.Mode == "direct" {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
return msOf(d)
|
||||||
|
}
|
||||||
|
cpu := "-"
|
||||||
|
if s.Mode != "direct" {
|
||||||
|
cpu = msOf(s.CPUPer1k)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(tw, "%s\t%d\t%d\t%d\t%s\t%s\t%s\t%s\t%s\n", s.Mode, s.Rate, s.Sent, s.Failed,
|
||||||
|
msOf(s.P50), msOf(s.P99), added(s.AddedP50), added(s.AddedP99), cpu)
|
||||||
|
}
|
||||||
|
_ = tw.Flush()
|
||||||
|
fmt.Fprintf(w, "\n%d CPUs, %s. Loopback, in one process: the figures are this machine's.\n",
|
||||||
|
runtime.NumCPU(), loadAverage())
|
||||||
|
}
|
||||||
|
|
||||||
|
func msOf(d time.Duration) string { return fmt.Sprintf("%.2fms", float64(d)/float64(time.Millisecond)) }
|
||||||
|
|
||||||
|
// loadAverage is the machine's, where it can be read, to keep beside the
|
||||||
|
// figures.
|
||||||
|
func loadAverage() string {
|
||||||
|
if b, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||||
|
if f := strings.Fields(string(b)); len(f) >= 3 {
|
||||||
|
return "load " + strings.Join(f[:3], " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// macOS: "{ 1.23 1.10 0.98 }".
|
||||||
|
if b, err := exec.Command("sysctl", "-n", "vm.loadavg").Output(); err == nil {
|
||||||
|
if f := strings.Fields(strings.Trim(strings.TrimSpace(string(b)), "{}")); len(f) >= 3 {
|
||||||
|
return "load " + strings.Join(f[:3], " ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "load not read"
|
||||||
|
}
|
||||||
|
|
||||||
|
// runOverhead measures every mode at every rate.
|
||||||
|
func runOverhead(ctx context.Context, rates []int, each time.Duration) ([]overheadStep, error) {
|
||||||
|
engine, err := instantEngine()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer engine.Close()
|
||||||
|
dir, err := os.MkdirTemp("", "llmgw-overhead-")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
|
||||||
|
type target struct {
|
||||||
|
url, key string
|
||||||
|
stop func()
|
||||||
|
}
|
||||||
|
targets := map[string]target{}
|
||||||
|
defer func() {
|
||||||
|
for _, t := range targets {
|
||||||
|
if t.stop != nil {
|
||||||
|
t.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
for _, m := range overheadModes {
|
||||||
|
if !m.gateway {
|
||||||
|
targets[m.name] = target{url: engine.URL}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
url, key, stop, err := overheadGateway(ctx, engine.URL, filepath.Join(dir, m.name+".db"), m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", m.name, err)
|
||||||
|
}
|
||||||
|
targets[m.name] = target{url: url, key: key, stop: stop}
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Transport: &http.Transport{MaxIdleConns: 1024, MaxIdleConnsPerHost: 1024}, Timeout: 30 * time.Second}
|
||||||
|
var out []overheadStep
|
||||||
|
for _, rate := range rates {
|
||||||
|
var direct overheadStep
|
||||||
|
for _, m := range overheadModes {
|
||||||
|
t := targets[m.name]
|
||||||
|
s := loadStep(ctx, client, t.url, t.key, rate, each)
|
||||||
|
s.Mode = m.name
|
||||||
|
if m.name == "direct" {
|
||||||
|
direct = s
|
||||||
|
} else {
|
||||||
|
s.AddedP50, s.AddedP99 = max(0, s.P50-direct.P50), max(0, s.P99-direct.P99)
|
||||||
|
if s.Sent > 0 {
|
||||||
|
s.CPUPer1k = max(0, s.cpu-direct.cpu) * 1000 / time.Duration(s.Sent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// loadStep sends rate requests a second for d, each in its own goroutine,
|
||||||
|
// and measures each one's latency and the process's CPU meanwhile.
|
||||||
|
func loadStep(ctx context.Context, client *http.Client, url, key string, rate int, d time.Duration) overheadStep {
|
||||||
|
s := overheadStep{Rate: rate}
|
||||||
|
body := []byte(`{"model":"bench","max_tokens":8,"messages":[{"role":"user","content":"What is the capital of France?"}]}`)
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
lat []time.Duration
|
||||||
|
wg sync.WaitGroup
|
||||||
|
total = int(float64(rate) * d.Seconds())
|
||||||
|
)
|
||||||
|
interval := time.Second / time.Duration(max(rate, 1))
|
||||||
|
cpu0 := processCPU()
|
||||||
|
start := time.Now()
|
||||||
|
for i := range total {
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if wait := time.Until(start.Add(time.Duration(i) * interval)); wait > 0 {
|
||||||
|
time.Sleep(wait)
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url+"/v1/chat/completions", bytes.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if key != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
}
|
||||||
|
t := time.Now()
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
ok := err == nil && resp.StatusCode == http.StatusOK
|
||||||
|
if resp != nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
}
|
||||||
|
el := time.Since(t)
|
||||||
|
mu.Lock()
|
||||||
|
s.Sent++
|
||||||
|
if ok {
|
||||||
|
lat = append(lat, el)
|
||||||
|
} else {
|
||||||
|
s.Failed++
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
s.cpu = processCPU() - cpu0
|
||||||
|
s.P50, s.P99 = percentile(lat, 0.50), percentile(lat, 0.99)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentile(ds []time.Duration, p float64) time.Duration {
|
||||||
|
if len(ds) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
ds = slices.Clone(ds)
|
||||||
|
sort.Slice(ds, func(i, j int) bool { return ds[i] < ds[j] })
|
||||||
|
i := int(p * float64(len(ds)-1))
|
||||||
|
return ds[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
// processCPU is the CPU time this process has used, user and system.
|
||||||
|
func processCPU() time.Duration {
|
||||||
|
var ru syscall.Rusage
|
||||||
|
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &ru); err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return time.Duration(ru.Utime.Nano() + ru.Stime.Nano())
|
||||||
|
}
|
||||||
|
|
||||||
|
// instantEngine is an OpenAI-compatible engine that answers at once, with
|
||||||
|
// the metrics the router scrapes.
|
||||||
|
func instantEngine() (*httpServer, error) {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
fmt.Fprint(w, "# TYPE vllm:num_requests_running gauge\nvllm:num_requests_running 0\n"+
|
||||||
|
"# TYPE vllm:num_requests_waiting gauge\nvllm:num_requests_waiting 0\n"+
|
||||||
|
"# TYPE vllm:kv_cache_usage_perc gauge\nvllm:kv_cache_usage_perc 0.1\n")
|
||||||
|
})
|
||||||
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||||
|
mux.HandleFunc("POST /v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = io.Copy(io.Discard, r.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"id":"b","object":"chat.completion","model":"bench","choices":[{"index":0,"message":{"role":"assistant","content":"Paris."},"finish_reason":"stop"}],"usage":{"prompt_tokens":14,"completion_tokens":2,"total_tokens":16}}`)
|
||||||
|
})
|
||||||
|
return serveLoopback(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpServer is a handler served on a loopback port.
|
||||||
|
type httpServer struct {
|
||||||
|
URL string
|
||||||
|
srv *http.Server
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *httpServer) Close() { _ = s.srv.Close() }
|
||||||
|
|
||||||
|
func serveLoopback(h http.Handler) (*httpServer, error) {
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second}
|
||||||
|
go func() { _ = srv.Serve(ln) }()
|
||||||
|
return &httpServer{URL: "http://" + ln.Addr().String(), srv: srv}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// overheadGateway starts a gateway in front of the engine as the mode asks,
|
||||||
|
// and returns its address and, with auth, a key.
|
||||||
|
func overheadGateway(ctx context.Context, engineURL, storePath string, m overheadMode) (string, string, func(), error) {
|
||||||
|
cfg := config.Default()
|
||||||
|
cfg.Listen = "127.0.0.1:0"
|
||||||
|
cfg.Router.ScrapeInterval = 200 * time.Millisecond
|
||||||
|
cfg.Endpoints = []config.Endpoint{{ID: "bench", URL: engineURL, Models: []string{"bench"}}}
|
||||||
|
key := ""
|
||||||
|
if m.auth {
|
||||||
|
var err error
|
||||||
|
if key, err = overheadKey(ctx, storePath); err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
cfg.Auth.Enabled, cfg.Auth.Store = true, storePath
|
||||||
|
// Limits would measure themselves refusing: off.
|
||||||
|
cfg.Auth.DefaultRPM, cfg.Auth.DefaultTPM = 0, 0
|
||||||
|
}
|
||||||
|
rt, err := router.New(cfg, slog.New(slog.DiscardHandler))
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
if m.guard {
|
||||||
|
g, err := baselineGuard()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
rt.SetGuard(g)
|
||||||
|
}
|
||||||
|
rctx, cancel := context.WithCancel(ctx)
|
||||||
|
rt.Start(rctx)
|
||||||
|
srv, err := serveLoopback(rt.Handler())
|
||||||
|
if err != nil {
|
||||||
|
cancel()
|
||||||
|
return "", "", nil, err
|
||||||
|
}
|
||||||
|
stop := func() { srv.Close(); cancel(); rt.Stop() }
|
||||||
|
// Ready once a request goes through: the endpoint is scraped first.
|
||||||
|
deadline := time.Now().Add(10 * time.Second)
|
||||||
|
for {
|
||||||
|
body := `{"model":"bench","messages":[{"role":"user","content":"hi"}]}`
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/v1/chat/completions", strings.NewReader(body))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if key != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+key)
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err == nil {
|
||||||
|
_, _ = io.Copy(io.Discard, resp.Body)
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return srv.URL, key, stop, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
stop()
|
||||||
|
status := "no answer"
|
||||||
|
if err == nil {
|
||||||
|
status = resp.Status
|
||||||
|
}
|
||||||
|
return "", "", nil, fmt.Errorf("the gateway did not serve within 10s (%s)", status)
|
||||||
|
}
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// overheadKey makes a store with one project and a key in it.
|
||||||
|
func overheadKey(ctx context.Context, path string) (string, error) {
|
||||||
|
st, err := storepkg.Open(ctx, path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
org, err := st.CreateOrg(ctx, "bench")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
team, err := st.CreateTeam(ctx, org, "bench")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
project, err := st.CreateProject(ctx, team, "bench")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
secret, k, err := storepkg.NewKey("key-bench", "bench", project)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
k.CreatedAt = time.Now()
|
||||||
|
return secret, st.CreateKey(ctx, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
// baselineGuard is the policy init installs, without its judge: the pattern
|
||||||
|
// checks every request passes.
|
||||||
|
func baselineGuard() (guard.Guard, error) {
|
||||||
|
m, err := config.Parse([]byte(guardSection(false)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cfg, err := config.FromMap(m)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return guard.NewGate(cfg.Guard, nil, slog.New(slog.DiscardHandler))
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The short version, so CI sees the gateway's overhead on every change: every
|
||||||
|
// mode answers every request, and what the gateway adds is measured, not
|
||||||
|
// guessed. No bound is asserted on it, as CI's machines vary; the table is
|
||||||
|
// in the test's log.
|
||||||
|
func TestTheGatewaysOverheadIsMeasured(t *testing.T) {
|
||||||
|
steps, err := runOverhead(t.Context(), []int{50}, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(steps) != len(overheadModes) {
|
||||||
|
t.Fatalf("%d steps for %d modes", len(steps), len(overheadModes))
|
||||||
|
}
|
||||||
|
for _, s := range steps {
|
||||||
|
if s.Sent == 0 || s.Failed > 0 || s.P50 <= 0 || s.P99 < s.P50 {
|
||||||
|
t.Errorf("%s: sent %d failed %d p50 %s p99 %s", s.Mode, s.Sent, s.Failed, s.P50, s.P99)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
printOverhead(&b, steps)
|
||||||
|
if !strings.Contains(b.String(), "gateway+auth+guard") {
|
||||||
|
t.Errorf("table:\n%s", b.String())
|
||||||
|
}
|
||||||
|
t.Logf("\n%s", b.String())
|
||||||
|
}
|
||||||
@@ -243,6 +243,10 @@ var liveSettings = []string{
|
|||||||
"firewall", "firewall_networks",
|
"firewall", "firewall_networks",
|
||||||
// Sent to the nodes' agents as they change.
|
// Sent to the nodes' agents as they change.
|
||||||
"agents",
|
"agents",
|
||||||
|
// Read by the leader's presets pass each time it runs.
|
||||||
|
"presets",
|
||||||
|
// Handed to the reconciler as they change.
|
||||||
|
"on_demand",
|
||||||
// Read by server init, node add and backups, not by the server.
|
// Read by server init, node add and backups, not by the server.
|
||||||
"backup_recipients", "backup_copy_to",
|
"backup_recipients", "backup_copy_to",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/comfy"
|
||||||
|
)
|
||||||
|
|
||||||
|
// comfyKeyEnv carries the replica's key to the gate: the environment, which
|
||||||
|
// only the engine's user and root can read, rather than the command line,
|
||||||
|
// which every user on the node can.
|
||||||
|
const comfyKeyEnv = "LLMGW_ENGINE_KEY"
|
||||||
|
|
||||||
|
func newComfyUIGateCmd() *cobra.Command {
|
||||||
|
var cfg comfy.GateConfig
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "comfyui-gate [flags] [-- main.py flags]",
|
||||||
|
Short: "Run ComfyUI as an engine: behind a key, on the loopback (started by the agent)",
|
||||||
|
Hidden: true,
|
||||||
|
Long: "Started by the node agent for a replica whose engine is comfyui. ComfyUI asks\n" +
|
||||||
|
"nobody for a key, so it listens on the loopback only and this gate on the\n" +
|
||||||
|
"replica's port, letting through, with the replica's key, only what the router\n" +
|
||||||
|
"asks of it. It also lays the fetched weights out where ComfyUI looks for them.",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
cfg.Key = os.Getenv(comfyKeyEnv)
|
||||||
|
_ = os.Unsetenv(comfyKeyEnv)
|
||||||
|
cfg.Args = args
|
||||||
|
cfg.Log = logger()
|
||||||
|
return comfy.RunGate(ctx, cfg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := cmd.Flags()
|
||||||
|
f.StringVar(&cfg.Listen, "listen", "", "the replica's address")
|
||||||
|
f.StringVar(&cfg.Model, "model", "", "the model's name, for /v1/models")
|
||||||
|
f.StringVar(&cfg.Weights, "weights", "", "the fetched weights: a file or a directory")
|
||||||
|
f.StringVar(&cfg.State, "state", "", "the replica's own directory")
|
||||||
|
f.StringVar(&cfg.Python, "python", "python3", "the interpreter of ComfyUI's environment")
|
||||||
|
f.StringVar(&cfg.Dir, "comfyui", "", "ComfyUI's directory, where main.py is")
|
||||||
|
for _, n := range []string{"listen", "weights", "state", "comfyui"} {
|
||||||
|
_ = cmd.MarkFlagRequired(n)
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -116,6 +116,11 @@ func (c *ctlClient) do(ctx context.Context, method, path string, body, out any)
|
|||||||
if out == nil {
|
if out == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// A stream, as it comes: NDJSON a long run writes line by line.
|
||||||
|
if w, ok := out.(io.Writer); ok {
|
||||||
|
_, err := io.Copy(w, resp.Body)
|
||||||
|
return err
|
||||||
|
}
|
||||||
// An older control plane answers a path it does not know with its
|
// An older control plane answers a path it does not know with its
|
||||||
// dashboard: a page, which read as JSON said only "invalid character '<'".
|
// dashboard: a page, which read as JSON said only "invalid character '<'".
|
||||||
if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
|
if strings.HasPrefix(resp.Header.Get("Content-Type"), "text/html") {
|
||||||
@@ -173,6 +178,7 @@ func newCtlCmd() *cobra.Command {
|
|||||||
newRetryCmd(client),
|
newRetryCmd(client),
|
||||||
newUpgradeCmd(client),
|
newUpgradeCmd(client),
|
||||||
newJobsCmd(client, &asJSON),
|
newJobsCmd(client, &asJSON),
|
||||||
|
newMeshJobsCmd(client, &asJSON),
|
||||||
newEngineCheckCmd(),
|
newEngineCheckCmd(),
|
||||||
newEngineBuildCmd(client),
|
newEngineBuildCmd(client),
|
||||||
newToolCheckCmd(client),
|
newToolCheckCmd(client),
|
||||||
@@ -180,7 +186,9 @@ func newCtlCmd() *cobra.Command {
|
|||||||
newCapacityCmd(client, &asJSON),
|
newCapacityCmd(client, &asJSON),
|
||||||
newQuotaCmd(client, &asJSON),
|
newQuotaCmd(client, &asJSON),
|
||||||
newGuardCmd(client, &asJSON),
|
newGuardCmd(client, &asJSON),
|
||||||
|
newMCPCmd(client, &asJSON),
|
||||||
newKBCmd(client, &asJSON),
|
newKBCmd(client, &asJSON),
|
||||||
|
newTrainCmd(client, &asJSON),
|
||||||
newConfigCmd(client, &asJSON),
|
newConfigCmd(client, &asJSON),
|
||||||
newAuditCmd(client, &asJSON),
|
newAuditCmd(client, &asJSON),
|
||||||
newNotifyCmd(client, &asJSON),
|
newNotifyCmd(client, &asJSON),
|
||||||
@@ -325,6 +333,9 @@ func modelStatus(m adminapi.ModelView) string {
|
|||||||
return "stopped"
|
return "stopped"
|
||||||
case m.Ready >= m.Min && m.Min > 0:
|
case m.Ready >= m.Min && m.Min > 0:
|
||||||
return "ready"
|
return "ready"
|
||||||
|
case m.Min == 0 && m.Ready > 0 && m.Ready >= m.Total:
|
||||||
|
// Asked for, and up until it goes unused for long enough.
|
||||||
|
return "ready (on demand)"
|
||||||
case m.Total > m.Ready:
|
case m.Total > m.Ready:
|
||||||
return "starting"
|
return "starting"
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newMeshJobsCmd is the 3D models clients asked for, as jobs: every owner's,
|
||||||
|
// what each is doing, and a way to stop one. (ctl jobs is the control
|
||||||
|
// plane's own work, serving and updating models.)
|
||||||
|
func newMeshJobsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "3d",
|
||||||
|
Short: "3D models clients asked for: the jobs, how far along each is, and stopping one",
|
||||||
|
}
|
||||||
|
var status string
|
||||||
|
var limit int
|
||||||
|
list := &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "Every owner's 3D jobs, newest first",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
q := url.Values{}
|
||||||
|
if status != "" {
|
||||||
|
q.Set("status", status)
|
||||||
|
}
|
||||||
|
if limit > 0 {
|
||||||
|
q.Set("limit", fmt.Sprint(limit))
|
||||||
|
}
|
||||||
|
var jobs []adminapi.JobView
|
||||||
|
if err := client().get(cmd.Context(), "/api/v1/client-jobs?"+q.Encode(), &jobs); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *asJSON {
|
||||||
|
return emitJSON(jobs)
|
||||||
|
}
|
||||||
|
if len(jobs) == 0 {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "no 3D jobs")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w := tabTo(cmd.OutOrStdout())
|
||||||
|
fmt.Fprintln(w, "JOB\tMODEL\tSTATUS\tPROGRESS\tOWNER\tASKED")
|
||||||
|
for _, j := range jobs {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s ago\n", j.ID, j.Model, j.Status, progressText(j),
|
||||||
|
j.Owner, time.Since(j.CreatedAt).Round(time.Second))
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
list.Flags().StringVar(&status, "status", "", "only jobs in this status: queued, running, completed, failed…")
|
||||||
|
list.Flags().IntVar(&limit, "limit", 0, "the most jobs to show (50)")
|
||||||
|
get := &cobra.Command{
|
||||||
|
Use: "get JOB",
|
||||||
|
Short: "One 3D job: its status, progress, result or error",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var j adminapi.JobView
|
||||||
|
if err := client().get(cmd.Context(), "/api/v1/client-jobs/"+url.PathEscape(args[0]), &j); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *asJSON {
|
||||||
|
return emitJSON(j)
|
||||||
|
}
|
||||||
|
out := cmd.OutOrStdout()
|
||||||
|
fmt.Fprintf(out, "job %s\nmodel %s\nstatus %s\n", j.ID, j.Model, j.Status)
|
||||||
|
if p := progressText(j); p != "" {
|
||||||
|
fmt.Fprintf(out, "progress %s\n", p)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "owner %s (key %s)\nasked %s\n", j.Owner, j.KeyID, j.CreatedAt.UTC().Format(time.RFC3339))
|
||||||
|
if j.FinishedAt != nil && j.StartedAt != nil {
|
||||||
|
fmt.Fprintf(out, "took %s\n", j.FinishedAt.Sub(*j.StartedAt).Round(time.Second))
|
||||||
|
}
|
||||||
|
if j.OutputFileID != "" {
|
||||||
|
fmt.Fprintf(out, "result %s (%s)\n", j.OutputFileID, j.ContentType)
|
||||||
|
}
|
||||||
|
if j.Error != "" {
|
||||||
|
fmt.Fprintf(out, "error %s\n", j.Error)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cancel := &cobra.Command{
|
||||||
|
Use: "cancel JOB",
|
||||||
|
Short: "Stop a 3D job, in the engine's queue too",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var j adminapi.JobView
|
||||||
|
if err := client().post(cmd.Context(), "/api/v1/client-jobs/"+url.PathEscape(args[0])+"/cancel", nil, &j); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%s is %s\n", j.ID, j.Status)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
cmd.AddCommand(list, get, cancel)
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// progressText is a job's progress as a person reads it.
|
||||||
|
func progressText(j adminapi.JobView) string {
|
||||||
|
if len(j.Progress) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var p struct {
|
||||||
|
State string `json:"state"`
|
||||||
|
Ahead int `json:"ahead"`
|
||||||
|
NodeClass string `json:"node_class"`
|
||||||
|
Step int `json:"step"`
|
||||||
|
Steps int `json:"steps"`
|
||||||
|
Percent int `json:"percent"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(j.Progress, &p) != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case p.State == "queued" && p.Ahead > 0:
|
||||||
|
return fmt.Sprintf("queued, %d ahead", p.Ahead)
|
||||||
|
case p.State == "running" && p.Steps > 0:
|
||||||
|
return fmt.Sprintf("running %d%%, %s step %d/%d", p.Percent, p.NodeClass, p.Step, p.Steps)
|
||||||
|
case p.State == "running" && p.NodeClass != "":
|
||||||
|
return "running " + p.NodeClass
|
||||||
|
}
|
||||||
|
return p.State
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newMCPCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: "mcp",
|
||||||
|
Short: "The company's own MCP servers, served through llmgw's /mcp",
|
||||||
|
Long: "An MCP server registered here — Jira, a file server, a database — is served\n" +
|
||||||
|
"through llmgw's /mcp beside llmgw's own tools, its tools named SERVER.tool.\n" +
|
||||||
|
"Its credentials stay on the control planes: a client gives only its llmgw\n" +
|
||||||
|
"key. A key sees and calls only the tools granted it, as patterns, with\n" +
|
||||||
|
"`llmgw keys limits --tools 'jira.*'` at any level of the tenancy tree;\n" +
|
||||||
|
"none granted anywhere is none. Every call is in `llmgw ctl audit`.",
|
||||||
|
}
|
||||||
|
c.AddCommand(newMCPAddCmd(client, asJSON), newMCPListCmd(client, asJSON), newMCPRemoveCmd(client),
|
||||||
|
newMCPTestCmd(client, asJSON))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMCPAddCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||||
|
var req adminapi.MCPServerRequest
|
||||||
|
var headers, refs, secretFiles []string
|
||||||
|
var bearerFile string
|
||||||
|
var disabled bool
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: "add NAME --url URL",
|
||||||
|
Short: "Register an MCP server, or change one",
|
||||||
|
Example: " llmgw ctl mcp add jira --url https://jira.internal/mcp --bearer-file /root/jira-token\n" +
|
||||||
|
" llmgw ctl mcp add llmgw --url llmgw:admin --description \"this cluster, read only\"\n" +
|
||||||
|
" llmgw ctl mcp add wiki --url https://wiki.internal/mcp --header-ref Authorization=vault:secret/mcp/wiki#token\n" +
|
||||||
|
" llmgw ctl mcp add jira --disabled # off, kept",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var err error
|
||||||
|
if req.Headers, err = pairs(headers, "--header"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if req.HeaderRefs, err = pairs(refs, "--header-ref"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
files, err := pairs(secretFiles, "--secret-file")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A secret read here and kept in the store, sealed: the file
|
||||||
|
// need not be on every control plane, and is not read again.
|
||||||
|
for header, file := range files {
|
||||||
|
b, err := os.ReadFile(file) //nolint:gosec // a file the operator names
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if req.Secrets == nil {
|
||||||
|
req.Secrets = map[string]string{}
|
||||||
|
}
|
||||||
|
req.Secrets[header] = strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
if bearerFile != "" {
|
||||||
|
b, err := os.ReadFile(bearerFile) //nolint:gosec // a file the operator names
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if req.Secrets == nil {
|
||||||
|
req.Secrets = map[string]string{}
|
||||||
|
}
|
||||||
|
req.Secrets["Authorization"] = "Bearer " + strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
if cmd.Flags().Changed("disabled") {
|
||||||
|
on := !disabled
|
||||||
|
req.Enabled = &on
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(req)
|
||||||
|
var v adminapi.MCPServerView
|
||||||
|
if err := client().put(cmd.Context(), "/api/v1/mcp/servers/"+args[0], body, &v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *asJSON {
|
||||||
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
|
||||||
|
}
|
||||||
|
state := "on"
|
||||||
|
if !v.Enabled {
|
||||||
|
state = "off"
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s, %s. Its tools are %s.TOOL in /mcp, to keys granted them "+
|
||||||
|
"(llmgw keys limits --tools '%s.*'). Check it: llmgw ctl mcp test %s\n", v.Name, v.URL, state, v.Name, v.Name, v.Name)
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
f := c.Flags()
|
||||||
|
f.StringVar(&req.URL, "url", "", "the server's streamable HTTP address, https://…/mcp; llmgw:admin for this cluster, read only")
|
||||||
|
f.StringVar(&req.Description, "description", "", "what it is, shown beside its tools")
|
||||||
|
f.StringArrayVar(&headers, "header", nil, "NAME=VALUE sent as written, for what is not secret; repeatable")
|
||||||
|
f.StringArrayVar(&refs, "header-ref", nil, "NAME=REF read at each call: a file on every control plane, env:, vault: or store:; repeatable")
|
||||||
|
f.StringArrayVar(&secretFiles, "secret-file", nil, "NAME=FILE read now and kept in the store, sealed: a token; repeatable")
|
||||||
|
f.StringVar(&bearerFile, "bearer-file", "", "a token FILE, read now and kept sealed, sent as Authorization: Bearer")
|
||||||
|
f.BoolVar(&disabled, "disabled", false, "keep it but serve none of its tools")
|
||||||
|
f.IntVar(&req.TimeoutSeconds, "timeout", 0, "seconds one call may take (default 60)")
|
||||||
|
f.IntVar(&req.RatePerMinute, "rate", 0, "calls a minute each caller may make to its tools (default: no bound; -1 takes one away)")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMCPListCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "list",
|
||||||
|
Short: "The MCP servers /mcp serves",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
var list []adminapi.MCPServerView
|
||||||
|
if err := client().get(cmd.Context(), "/api/v1/mcp/servers", &list); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *asJSON {
|
||||||
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(list)
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "no MCP servers; add one: llmgw ctl mcp add NAME --url URL")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
w := tabTo(cmd.OutOrStdout())
|
||||||
|
fmt.Fprintln(w, "NAME\tSTATE\tURL\tCREDENTIALS\tDESCRIPTION")
|
||||||
|
for _, s := range list {
|
||||||
|
state := "on"
|
||||||
|
if !s.Enabled {
|
||||||
|
state = "off"
|
||||||
|
}
|
||||||
|
var creds []string
|
||||||
|
for h := range s.HeaderRefs {
|
||||||
|
creds = append(creds, h)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", s.Name, state, s.URL, dash(strings.Join(creds, ",")), s.Description)
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMCPRemoveCmd(client func() *ctlClient) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "remove NAME",
|
||||||
|
Short: "Remove an MCP server: its tools leave /mcp",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if err := client().delete(cmd.Context(), "/api/v1/mcp/servers/"+args[0], nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%s removed; its tools leave /mcp within half a minute\n", args[0])
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMCPTestCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "test NAME",
|
||||||
|
Short: "Ask an MCP server for its tools, with its credentials, as /mcp would",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
var v adminapi.MCPTestView
|
||||||
|
if err := client().post(cmd.Context(), "/api/v1/mcp/servers/"+args[0]+"/test", nil, &v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *asJSON {
|
||||||
|
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
|
||||||
|
}
|
||||||
|
if !v.OK {
|
||||||
|
return errors.New(args[0] + " did not answer: " + v.Error)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), "%s answers with %d tool(s):\n", args[0], len(v.Tools))
|
||||||
|
for _, t := range v.Tools {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), " "+t)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pairs reads NAME=VALUE flags.
|
||||||
|
func pairs(list []string, flag string) (map[string]string, error) {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out := map[string]string{}
|
||||||
|
for _, p := range list {
|
||||||
|
k, v, ok := strings.Cut(p, "=")
|
||||||
|
if !ok || strings.TrimSpace(k) == "" {
|
||||||
|
return nil, fmt.Errorf("%s %q is not NAME=VALUE", flag, p)
|
||||||
|
}
|
||||||
|
out[strings.TrimSpace(k)] = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -56,6 +56,12 @@ func newRequestCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|||||||
fmt.Fprintf(out, "status %d\n", v.Status)
|
fmt.Fprintf(out, "status %d\n", v.Status)
|
||||||
fmt.Fprintf(out, "time %d ms total: %d queued, %d to the first token\n", v.LatencyMs, v.QueueMs, v.TTFTMs)
|
fmt.Fprintf(out, "time %d ms total: %d queued, %d to the first token\n", v.LatencyMs, v.QueueMs, v.TTFTMs)
|
||||||
fmt.Fprintf(out, "tokens %d in, %d out\n", v.PromptTokens, v.CompletionTokens)
|
fmt.Fprintf(out, "tokens %d in, %d out\n", v.PromptTokens, v.CompletionTokens)
|
||||||
|
if v.Images > 0 {
|
||||||
|
fmt.Fprintf(out, "images %d\n", v.Images)
|
||||||
|
}
|
||||||
|
if v.Meshes > 0 {
|
||||||
|
fmt.Fprintf(out, "meshes %d\n", v.Meshes)
|
||||||
|
}
|
||||||
if v.CacheHit {
|
if v.CacheHit {
|
||||||
fmt.Fprintf(out, "cache answered from the project's answer cache: nothing generated, nothing charged\n")
|
fmt.Fprintf(out, "cache answered from the project's answer cache: nothing generated, nothing charged\n")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -268,6 +268,26 @@ func upgradeNodes(ctx context.Context, out io.Writer, client func() *ctlClient,
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(out, "%d node(s) upgraded to %s in %s, %d already on it\n",
|
fmt.Fprintf(out, "%d node(s) upgraded to %s in %s, %d already on it\n",
|
||||||
done, v, time.Since(start).Round(time.Second), skipped)
|
done, v, time.Since(start).Round(time.Second), skipped)
|
||||||
|
// Checked at the end, against what the control plane says now: a node
|
||||||
|
// that is not on the version is said by name, and the command fails. A
|
||||||
|
// site's nodes stayed a release behind with nothing printed to say so.
|
||||||
|
if len(nodes) == 0 {
|
||||||
|
fmt.Fprintln(out, "This control plane has no nodes registered, so none were upgraded.")
|
||||||
|
}
|
||||||
|
var now []adminapi.NodeView
|
||||||
|
if err := client().get(ctx, "/api/v1/nodes", &now); err == nil {
|
||||||
|
var behind []string
|
||||||
|
for _, n := range now {
|
||||||
|
if (len(only) == 0 || slices.Contains(only, n.ID)) && n.AgentVersion != v {
|
||||||
|
behind = append(behind, fmt.Sprintf("%s (%s, %s)", n.ID, orUnknown(n.AgentVersion), n.State))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(behind) > 0 {
|
||||||
|
slices.Sort(behind)
|
||||||
|
return fmt.Errorf("not on %s: %s\nrun this again to try them, or `llmgw ctl logs NODE` for why",
|
||||||
|
v, strings.Join(behind, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
// The one machine this does not reach. Said every time, because it is
|
// The one machine this does not reach. Said every time, because it is
|
||||||
// exactly the kind of thing that stays three versions behind unnoticed.
|
// exactly the kind of thing that stays three versions behind unnoticed.
|
||||||
fmt.Fprintf(out, "The model mirror's host is not upgraded by this; on it, run:\n"+
|
fmt.Fprintf(out, "The model mirror's host is not upgraded by this; on it, run:\n"+
|
||||||
|
|||||||
@@ -150,3 +150,54 @@ func TestABinaryInstalledByHandIsNotAServerRunningIt(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnUpgradeThatFindsNoNodesSaysSo(t *testing.T) {
|
||||||
|
// A site's nodes stayed a release behind with nothing printed to say
|
||||||
|
// that none had been reached.
|
||||||
|
f := &fakeFleet{versions: map[string]string{}}
|
||||||
|
srv := f.server(t)
|
||||||
|
var out bytes.Buffer
|
||||||
|
if err := upgradeNodes(t.Context(), &out, func() *ctlClient { return newCtlClient(srv.URL) }, "v1.0.1", nil, time.Second, 1); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "no nodes registered") {
|
||||||
|
t.Errorf("output:\n%s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUpgradeFailsNamingANodeLeftBehind(t *testing.T) {
|
||||||
|
// A node that joins, or comes back on its old binary, while the waves
|
||||||
|
// run is found at the end rather than passed over.
|
||||||
|
f := &fakeFleet{versions: map[string]string{"a": "v1.0.0"}}
|
||||||
|
srv := f.server(t)
|
||||||
|
late := func() *ctlClient {
|
||||||
|
f.mu.Lock()
|
||||||
|
if len(f.told) == 1 {
|
||||||
|
f.versions["late"] = "v1.0.0"
|
||||||
|
}
|
||||||
|
f.mu.Unlock()
|
||||||
|
return newCtlClient(srv.URL)
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := upgradeNodes(t.Context(), &out, late, "v1.0.1", nil, 5*time.Second, 1)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "late (v1.0.0, ready)") {
|
||||||
|
t.Errorf("err %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpgradingAControlPlaneAloneSaysItsNodesWereNot(t *testing.T) {
|
||||||
|
f := &fakeFleet{versions: map[string]string{"p-g1": "v1.0.0", "p-g3": "v1.0.1"}}
|
||||||
|
srv := f.server(t)
|
||||||
|
var out bytes.Buffer
|
||||||
|
nodesLeftBehind(t.Context(), &out, newCtlClient(srv.URL), "v1.0.1")
|
||||||
|
if !strings.Contains(out.String(), "1 of 2 node(s) are not on v1.0.1") ||
|
||||||
|
!strings.Contains(out.String(), "ctl upgrade v1.0.1 --skip-control-plane") {
|
||||||
|
t.Errorf("output:\n%s", out.String())
|
||||||
|
}
|
||||||
|
out.Reset()
|
||||||
|
f.versions["p-g1"] = "v1.0.1"
|
||||||
|
nodesLeftBehind(t.Context(), &out, newCtlClient(srv.URL), "v1.0.1")
|
||||||
|
if out.Len() != 0 {
|
||||||
|
t.Errorf("said something with every node on the version: %s", out.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
-21
@@ -7,12 +7,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
||||||
)
|
)
|
||||||
|
|
||||||
// firewallStateFile is where a control plane writes what the cluster's
|
// firewallStateFile is where a control plane writes what the cluster's
|
||||||
@@ -20,11 +22,9 @@ import (
|
|||||||
// the server itself runs as llmgw and cannot change the ruleset.
|
// the server itself runs as llmgw and cannot change the ruleset.
|
||||||
const firewallStateFile = "/var/lib/llmgw/firewall.json"
|
const firewallStateFile = "/var/lib/llmgw/firewall.json"
|
||||||
|
|
||||||
// firewallState is what is written there.
|
// firewallState is what is written there: the same as the mirror is told
|
||||||
type firewallState struct {
|
// and keeps beside its models.
|
||||||
Enabled bool `json:"enabled"`
|
type firewallState = mirror.FirewallState
|
||||||
Networks []string `json:"networks,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeFirewallState records the settings' firewall for this machine's sync
|
// writeFirewallState records the settings' firewall for this machine's sync
|
||||||
// unit. Without the state directory — a server not run by systemd — there
|
// unit. Without the state directory — a server not run by systemd — there
|
||||||
@@ -46,25 +46,34 @@ func writeFirewallState(cfg config.Server) error {
|
|||||||
|
|
||||||
// The units that apply it: a path unit watching the file, and the oneshot it
|
// The units that apply it: a path unit watching the file, and the oneshot it
|
||||||
// starts.
|
// starts.
|
||||||
const (
|
const firewallSyncPathUnit = "llmgw-firewall-sync.path"
|
||||||
firewallSyncPathUnit = "llmgw-firewall-sync.path"
|
|
||||||
firewallSyncUnitFile = `[Unit]
|
// firewallSyncUnits are the two units for a state file: on a control plane
|
||||||
Description=llmgw firewall: apply the cluster's settings on this control plane
|
// the one its server writes, on a mirror the one the control plane has the
|
||||||
|
// mirror write.
|
||||||
|
func firewallSyncUnits(state string) (service, pathUnit string) {
|
||||||
|
arg := ""
|
||||||
|
if state != firewallStateFile {
|
||||||
|
arg = " --state " + state
|
||||||
|
}
|
||||||
|
service = `[Unit]
|
||||||
|
Description=llmgw firewall: apply the cluster's settings on this machine
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=oneshot
|
Type=oneshot
|
||||||
ExecStart=/usr/local/bin/llmgw firewall sync
|
ExecStart=/usr/local/bin/llmgw firewall sync` + arg + `
|
||||||
`
|
`
|
||||||
firewallSyncPathFile = `[Unit]
|
pathUnit = `[Unit]
|
||||||
Description=llmgw firewall: follow the cluster's settings on this control plane
|
Description=llmgw firewall: follow the cluster's settings on this machine
|
||||||
|
|
||||||
[Path]
|
[Path]
|
||||||
PathChanged=` + firewallStateFile + `
|
PathChanged=` + state + `
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
`
|
`
|
||||||
)
|
return service, pathUnit
|
||||||
|
}
|
||||||
|
|
||||||
func newFirewallCmd() *cobra.Command {
|
func newFirewallCmd() *cobra.Command {
|
||||||
c := &cobra.Command{
|
c := &cobra.Command{
|
||||||
@@ -76,6 +85,7 @@ func newFirewallCmd() *cobra.Command {
|
|||||||
"which " + firewallSyncPathUnit + " applies with 'llmgw firewall sync'.",
|
"which " + firewallSyncPathUnit + " applies with 'llmgw firewall sync'.",
|
||||||
}
|
}
|
||||||
var install bool
|
var install bool
|
||||||
|
state := firewallStateFile
|
||||||
sync := &cobra.Command{
|
sync := &cobra.Command{
|
||||||
Use: "sync",
|
Use: "sync",
|
||||||
Short: "Apply what the cluster's settings say of the firewall, here (root)",
|
Short: "Apply what the cluster's settings say of the firewall, here (root)",
|
||||||
@@ -85,21 +95,21 @@ func newFirewallCmd() *cobra.Command {
|
|||||||
return errors.New("the firewall is changed as root: run it with sudo")
|
return errors.New("the firewall is changed as root: run it with sudo")
|
||||||
}
|
}
|
||||||
if install {
|
if install {
|
||||||
if err := installFirewallSync(cmd.Context(), firewall.Local{}); err != nil {
|
if err := installFirewallSync(cmd.Context(), firewall.Local{}, state); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
fmt.Fprintln(cmd.OutOrStdout(), firewallSyncPathUnit+" follows the cluster's settings here")
|
fmt.Fprintln(cmd.OutOrStdout(), firewallSyncPathUnit+" follows the cluster's settings here")
|
||||||
if _, err := os.Stat(firewallStateFile); err != nil {
|
if _, err := os.Stat(state); err != nil {
|
||||||
return nil // the server writes it when it starts
|
return nil // written when the settings are first told
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
b, err := os.ReadFile(firewallStateFile)
|
b, err := os.ReadFile(state)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
var st firewallState
|
var st firewallState
|
||||||
if err := json.Unmarshal(b, &st); err != nil {
|
if err := json.Unmarshal(b, &st); err != nil {
|
||||||
return fmt.Errorf("%s: %w", firewallStateFile, err)
|
return fmt.Errorf("%s: %w", state, err)
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute)
|
ctx, cancel := context.WithTimeout(cmd.Context(), 2*time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
@@ -112,14 +122,17 @@ func newFirewallCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
sync.Flags().BoolVar(&install, "install", false, "first install the units that run this whenever the settings change")
|
sync.Flags().BoolVar(&install, "install", false, "first install the units that run this whenever the settings change")
|
||||||
|
sync.Flags().StringVar(&state, "state", firewallStateFile,
|
||||||
|
"the file the settings are written to: a control plane's, or "+mirror.FirewallFile+" in a mirror's directory")
|
||||||
c.AddCommand(sync)
|
c.AddCommand(sync)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// installFirewallSync writes the sync units on a machine and has the path
|
// installFirewallSync writes the sync units on a machine and has the path
|
||||||
// unit follow the state file.
|
// unit follow the state file.
|
||||||
func installFirewallSync(ctx context.Context, r firewall.Runner) error {
|
func installFirewallSync(ctx context.Context, r firewall.Runner, state string) error {
|
||||||
body := firewallSyncUnitFile + "\x00" + firewallSyncPathFile
|
service, pathUnit := firewallSyncUnits(state)
|
||||||
|
body := service + "\x00" + pathUnit
|
||||||
out, err := r.Run(ctx, `set -e
|
out, err := r.Run(ctx, `set -e
|
||||||
t=$(mktemp -d); trap 'rm -rf "$t"' EXIT
|
t=$(mktemp -d); trap 'rm -rf "$t"' EXIT
|
||||||
cat > "$t/all"
|
cat > "$t/all"
|
||||||
@@ -172,5 +185,20 @@ func syncFirewall(ctx context.Context, r firewall.Runner, st firewallState) (str
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The cluster's machines: a node on a network this one has not let in
|
||||||
|
// yet, which the mirror's files are for.
|
||||||
|
if len(st.Toward) > 0 {
|
||||||
|
added, err := firewall.Admit(ctx, r, st.Toward)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if len(added) > 0 {
|
||||||
|
shown := make([]string, len(added))
|
||||||
|
for i, p := range added {
|
||||||
|
shown[i] = p.String()
|
||||||
|
}
|
||||||
|
return "llmgw's firewall is on here, and now lets in " + strings.Join(shown, ", "), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
return "llmgw's firewall is on here", nil
|
return "llmgw's firewall is on here", nil
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,11 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/netip"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/firewall"
|
||||||
)
|
)
|
||||||
|
|
||||||
type scriptLog struct {
|
type scriptLog struct {
|
||||||
@@ -49,3 +52,32 @@ func TestAControlPlaneFollowsTheFirewallSetting(t *testing.T) {
|
|||||||
t.Errorf("on: %q %v", msg, err)
|
t.Errorf("on: %q %v", msg, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAMachineLetsInTheClusterMachinesItIsTold(t *testing.T) {
|
||||||
|
// The rules hold 10.0.0.0/24; a node on 192.168.7.0/24 is let in, as
|
||||||
|
// the route to it says, and one already in is not asked about.
|
||||||
|
r := &scriptLog{reply: func(s string) string {
|
||||||
|
switch {
|
||||||
|
case strings.Contains(s, "echo loaded"):
|
||||||
|
return "loaded\n"
|
||||||
|
case strings.Contains(s, "cat /etc/llmgw/firewall.nft"):
|
||||||
|
return firewallRulesFor("10.0.0.0/24")
|
||||||
|
case strings.Contains(s, "ip -o route get 192.168.7.20"):
|
||||||
|
return "192.168.7.20 dev eth1 src 192.168.7.1 uid 0\n2: eth1 inet 192.168.7.1/24 brd 192.168.7.255 scope global eth1\n"
|
||||||
|
case strings.Contains(s, "nft -f"):
|
||||||
|
return "changed\n"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}}
|
||||||
|
msg, err := syncFirewall(context.Background(), r, firewallState{Enabled: true, Toward: []string{"10.0.0.147", "192.168.7.20"}})
|
||||||
|
if err != nil || !strings.Contains(msg, "192.168.7.0/24") {
|
||||||
|
t.Fatalf("%q %v", msg, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.Join(r.scripts, "\n"), "route get 10.0.0.147") {
|
||||||
|
t.Error("asked the route to a machine already let in")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func firewallRulesFor(nets string) string {
|
||||||
|
return firewall.Script(firewall.Spec{Ports: []firewall.Port{firewall.MirrorFiles}, Allow: []netip.Prefix{netip.MustParsePrefix(nets)}})
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
|
"net/netip"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// firewallStateKey is where, in the store, the firewall's state is kept
|
||||||
|
// for the store's members to read.
|
||||||
|
const firewallStateKey = "firewall"
|
||||||
|
|
||||||
|
// firewallPublisher tells the machines that cannot ask a control plane
|
||||||
|
// what the cluster's settings say of their firewall: the mirror, over its
|
||||||
|
// API, and the store's members, through the store they hold a copy of.
|
||||||
|
// Nodes are told over their agents' streams and control planes by their
|
||||||
|
// own servers. On the leader only, at start, at every change to the
|
||||||
|
// firewall's settings, and every few minutes for nodes come and gone.
|
||||||
|
type firewallPublisher struct {
|
||||||
|
settings func() config.Server
|
||||||
|
st *store.DB
|
||||||
|
nodes func() []string
|
||||||
|
leader func() bool
|
||||||
|
log *slog.Logger
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
mirror *mirror.Client
|
||||||
|
kick chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newFirewallPublisher(settings func() config.Server, st *store.DB, nodes func() []string,
|
||||||
|
leader func() bool, log *slog.Logger) *firewallPublisher {
|
||||||
|
return &firewallPublisher{settings: settings, st: st, nodes: nodes, leader: leader, log: log,
|
||||||
|
kick: make(chan struct{}, 1)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setMirror has the mirror told too.
|
||||||
|
func (p *firewallPublisher) setMirror(m *mirror.Client) {
|
||||||
|
p.mu.Lock()
|
||||||
|
p.mirror = m
|
||||||
|
p.mu.Unlock()
|
||||||
|
p.Kick()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kick has the state worked out and told again now.
|
||||||
|
func (p *firewallPublisher) Kick() {
|
||||||
|
select {
|
||||||
|
case p.kick <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *firewallPublisher) run(ctx context.Context, every time.Duration) {
|
||||||
|
t := time.NewTicker(every)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
p.once(ctx)
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
case <-p.kick:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// state is what the settings say now, with the cluster's machines.
|
||||||
|
func (p *firewallPublisher) state(ctx context.Context) mirror.FirewallState {
|
||||||
|
cfg := p.settings()
|
||||||
|
st := mirror.FirewallState{Enabled: cfg.Firewall.Enabled, Networks: slices.Clone(cfg.FirewallNetworks)}
|
||||||
|
var toward []string
|
||||||
|
if p.nodes != nil {
|
||||||
|
toward = append(toward, p.nodes()...)
|
||||||
|
}
|
||||||
|
if p.st != nil {
|
||||||
|
if cps, err := p.st.ControlPlanes(ctx); err == nil {
|
||||||
|
for _, c := range cps {
|
||||||
|
toward = append(toward, c.URL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, t := range toward {
|
||||||
|
if a, ok := hostAddr(t); ok {
|
||||||
|
st.Toward = append(st.Toward, a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slices.Sort(st.Toward)
|
||||||
|
st.Toward = slices.Compact(st.Toward)
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostAddr is the address in an address, host:port or URL; names are not
|
||||||
|
// looked up here.
|
||||||
|
func hostAddr(s string) (string, bool) {
|
||||||
|
if u, err := url.Parse(s); err == nil && u.Host != "" {
|
||||||
|
s = u.Hostname()
|
||||||
|
} else if h, _, err := net.SplitHostPort(s); err == nil {
|
||||||
|
s = h
|
||||||
|
}
|
||||||
|
a, err := netip.ParseAddr(s)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return a.Unmap().String(), true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *firewallPublisher) once(ctx context.Context) {
|
||||||
|
if p.leader != nil && !p.leader() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
st := p.state(ctx)
|
||||||
|
p.mu.Lock()
|
||||||
|
m := p.mirror
|
||||||
|
p.mu.Unlock()
|
||||||
|
if m != nil {
|
||||||
|
changed, err := m.SetFirewall(ctx, st)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
p.log.Warn("could not tell the mirror what the settings say of its firewall", "err", err)
|
||||||
|
case changed:
|
||||||
|
p.log.Info("the mirror's firewall follows the cluster's settings", "enabled", st.Enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.st != nil {
|
||||||
|
b, _ := json.Marshal(st)
|
||||||
|
changed, err := p.st.PutClusterState(ctx, firewallStateKey, string(b))
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
p.log.Warn("could not record the firewall's state for the store's members", "err", err)
|
||||||
|
case changed:
|
||||||
|
p.log.Info("the store's members' firewall follows the cluster's settings", "enabled", st.Enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log/slog"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheLeaderTellsTheMirrorAndTheStoreWhatTheFirewallSettingsSay(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
st, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "s.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer st.Close()
|
||||||
|
if err := st.PutControlPlane(ctx, store.ControlPlane{URL: "https://10.0.0.150:7443", Name: "ctl02"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root := t.TempDir()
|
||||||
|
srv := httptest.NewServer(mirror.NewAPI(mirror.NewManager(root, slog.New(slog.DiscardHandler)), "http://m:8081").Handler())
|
||||||
|
defer srv.Close()
|
||||||
|
mc, _ := mirror.NewClient(srv.URL, tlsconf.Config{}, 0)
|
||||||
|
|
||||||
|
cfg := config.Server{Firewall: config.FirewallSettings{Enabled: false}, FirewallNetworks: []string{"192.168.5.0/24"}}
|
||||||
|
leader := false
|
||||||
|
p := newFirewallPublisher(func() config.Server { return cfg }, st,
|
||||||
|
func() []string { return []string{"10.0.0.147", "", "10.0.0.147", "node-by-name"} },
|
||||||
|
func() bool { return leader }, slog.New(slog.DiscardHandler))
|
||||||
|
p.setMirror(mc)
|
||||||
|
|
||||||
|
p.once(ctx)
|
||||||
|
if _, err := st.ClusterState(ctx, firewallStateKey); err == nil {
|
||||||
|
t.Error("a standby told the store")
|
||||||
|
}
|
||||||
|
leader = true
|
||||||
|
p.once(ctx)
|
||||||
|
var got mirror.FirewallState
|
||||||
|
v, err := st.ClusterState(ctx, firewallStateKey)
|
||||||
|
if err != nil || json.Unmarshal([]byte(v), &got) != nil {
|
||||||
|
t.Fatalf("the store holds %q %v", v, err)
|
||||||
|
}
|
||||||
|
if got.Enabled || got.Networks[0] != "192.168.5.0/24" || len(got.Toward) != 2 ||
|
||||||
|
got.Toward[0] != "10.0.0.147" || got.Toward[1] != "10.0.0.150" {
|
||||||
|
t.Errorf("state %+v", got)
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(filepath.Join(root, mirror.FirewallFile))
|
||||||
|
if err != nil || string(b) != v {
|
||||||
|
t.Errorf("the mirror holds %s, the store %s", b, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -33,7 +34,7 @@ func newGuardJudgeCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|||||||
"then fetches and serves by itself: " + knownJudgeNames() + ".",
|
"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))
|
newGuardJudgeUseCmd(client), newGuardJudgeAskCmd(client, asJSON), newGuardJudgeCheckCmd(client, asJSON))
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -383,3 +384,70 @@ func newGuardJudgeAskCmd(client func() *ctlClient, asJSON *bool) *cobra.Command
|
|||||||
c.Flags().StringVar(&answer, "answer", "", "an assistant's reply to TEXT, judged as output is")
|
c.Flags().StringVar(&answer, "answer", "", "an assistant's reply to TEXT, judged as output is")
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newGuardJudgeCheckCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||||
|
var model string
|
||||||
|
var burst int
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: "check",
|
||||||
|
Short: "How a judge keeps up: a judgment alone and among others, against the time a request waits",
|
||||||
|
Long: "Times three judgments of a prompt of about 280 tokens, one after another, then\n" +
|
||||||
|
"a burst of them at once (two for each ready replica unless --burst says),\n" +
|
||||||
|
"and sets both against the policies' latency budget. A judgment past the\n" +
|
||||||
|
"budget is refused under fail: closed; past half of it alone, or past it\n" +
|
||||||
|
"among others, a busy moment refuses requests. Each request is judged once\n" +
|
||||||
|
"for its prompt and again every 600 characters of its answer.",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
v, err := checkJudge(cmd.Context(), client(), model, burst)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *asJSON {
|
||||||
|
return emitJSON(v)
|
||||||
|
}
|
||||||
|
fmt.Fprint(cmd.OutOrStdout(), judgeCheckText(v))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.Flags().StringVar(&model, "model", "", "the judge (default: the one the policies ask)")
|
||||||
|
c.Flags().IntVar(&burst, "burst", 0, "judgments at once (default: two for each ready replica)")
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkJudge(ctx context.Context, c *ctlClient, model string, burst int) (adminapi.JudgeCheckView, error) {
|
||||||
|
body, _ := json.Marshal(adminapi.JudgeCheckRequest{Model: model, Burst: burst})
|
||||||
|
var v adminapi.JudgeCheckView
|
||||||
|
err := c.patient(3*time.Minute).post(ctx, "/api/v1/guard/judges/check", body, &v)
|
||||||
|
return v, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeCheckText says a check in a few lines, and what to do when it is not
|
||||||
|
// fine.
|
||||||
|
func judgeCheckText(v adminapi.JudgeCheckView) string {
|
||||||
|
var b strings.Builder
|
||||||
|
took := make([]string, len(v.TookMs))
|
||||||
|
for i, t := range v.TookMs {
|
||||||
|
took[i] = fmt.Sprintf("%.1f", float64(t)/1000)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "%s, %d replica(s) ready, a request waits %.1f s for it\n", v.Model, v.Ready, float64(v.BudgetMs)/1000)
|
||||||
|
fmt.Fprintf(&b, " alone: %s s\n", strings.Join(took, ", "))
|
||||||
|
fmt.Fprintf(&b, " %d at once: slowest %.1f s", v.Burst, float64(v.BurstSlowestMs)/1000)
|
||||||
|
if v.BurstFailed > 0 {
|
||||||
|
fmt.Fprintf(&b, ", %d gave no verdict", v.BurstFailed)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(&b, "; about %.1f judgments a second, so about %.1f requests a second with short answers\n",
|
||||||
|
v.PerSecond, v.PerSecond/2)
|
||||||
|
switch v.Verdict {
|
||||||
|
case "ok":
|
||||||
|
b.WriteString("ok: well inside the budget\n")
|
||||||
|
case "tight":
|
||||||
|
b.WriteString("tight: a busy moment passes the budget\n")
|
||||||
|
default:
|
||||||
|
b.WriteString("slow: a judgment alone passes the budget\n")
|
||||||
|
}
|
||||||
|
for _, a := range v.Advice {
|
||||||
|
b.WriteString(" - " + a + "\n")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
@@ -32,12 +32,34 @@ type judgeKeeper struct {
|
|||||||
// tried is when each judge was last asked for, so one that cannot be
|
// tried is when each judge was last asked for, so one that cannot be
|
||||||
// fetched is not asked for again on every look.
|
// fetched is not asked for again on every look.
|
||||||
tried map[string]time.Time
|
tried map[string]time.Time
|
||||||
|
// grace is how long after its first look the keeper serves no judge
|
||||||
|
// itself: init adds its own judge just after the control plane starts,
|
||||||
|
// sized and placed as the install says, and one the leader added first
|
||||||
|
// was what init then kept. Zero waits for nothing.
|
||||||
|
grace time.Duration
|
||||||
|
first time.Time
|
||||||
|
|
||||||
|
// What refitting an earlier install's judge needs: the catalog to change
|
||||||
|
// its profile in, the replicas to restart, and the nodes' CPUs. Any nil
|
||||||
|
// leaves judges as they are.
|
||||||
|
profiles interface {
|
||||||
|
SetProfiles(ctx context.Context, name string, profiles []catalog.Profile, by string) (catalog.Model, error)
|
||||||
|
}
|
||||||
|
restart func(ids ...string) error
|
||||||
|
replicas func(model string) []string
|
||||||
|
nodeCPUs func() []int
|
||||||
|
// looked is each judge already refitted or found changed by hand, so
|
||||||
|
// each is said once.
|
||||||
|
looked map[string]bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// judgeRetry is how long a judge whose serving failed waits before it is
|
// judgeRetry is how long a judge whose serving failed waits before it is
|
||||||
// asked for again.
|
// asked for again.
|
||||||
const judgeRetry = 10 * time.Minute
|
const judgeRetry = 10 * time.Minute
|
||||||
|
|
||||||
|
// judgeGrace is the keeper's grace on a control plane just started.
|
||||||
|
const judgeGrace = 10 * time.Minute
|
||||||
|
|
||||||
func (k *judgeKeeper) run(ctx context.Context, every time.Duration) {
|
func (k *judgeKeeper) run(ctx context.Context, every time.Duration) {
|
||||||
t := time.NewTicker(every)
|
t := time.NewTicker(every)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
@@ -69,6 +91,13 @@ func (k *judgeKeeper) check() {
|
|||||||
have := map[string]bool{}
|
have := map[string]bool{}
|
||||||
for _, m := range k.models() {
|
for _, m := range k.models() {
|
||||||
have[m.Name] = true
|
have[m.Name] = true
|
||||||
|
k.refit(m)
|
||||||
|
}
|
||||||
|
if k.first.IsZero() {
|
||||||
|
k.first = now()
|
||||||
|
}
|
||||||
|
if now().Sub(k.first) < k.grace {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
busy := map[string]bool{}
|
busy := map[string]bool{}
|
||||||
for _, j := range k.serve.Jobs() {
|
for _, j := range k.serve.Jobs() {
|
||||||
@@ -96,6 +125,15 @@ func (k *judgeKeeper) check() {
|
|||||||
}
|
}
|
||||||
k.tried[name] = now()
|
k.tried[name] = now()
|
||||||
var opts judgeServeOptions
|
var opts judgeServeOptions
|
||||||
|
if !kj.GPU {
|
||||||
|
// The cores init would give the same number of judges.
|
||||||
|
var cpus []int
|
||||||
|
if k.nodeCPUs != nil {
|
||||||
|
cpus = k.nodeCPUs()
|
||||||
|
}
|
||||||
|
opts.Replicas = 2
|
||||||
|
opts.Cores = refitCores(opts.Replicas, cpus)
|
||||||
|
}
|
||||||
if kj.GPU {
|
if kj.GPU {
|
||||||
if opts.GPUClass = roomiestGPUClass(k.gpus()); opts.GPUClass == "" {
|
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",
|
k.log.Warn("a policy names a judge that runs on a GPU, and this cluster has none",
|
||||||
@@ -105,6 +143,8 @@ func (k *judgeKeeper) check() {
|
|||||||
}
|
}
|
||||||
req := kj.serveRequest(opts)
|
req := kj.serveRequest(opts)
|
||||||
req.By = "llmgw (a guard policy names it)"
|
req.By = "llmgw (a guard policy names it)"
|
||||||
|
// init, or an operator, may add it while this fetches: theirs stays.
|
||||||
|
req.IfAbsent = true
|
||||||
job, err := k.serve.Start(req)
|
job, err := k.serve.Start(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
k.log.Error("could not start serving a judge a policy names", "judge", name, "err", err)
|
k.log.Error("could not start serving a judge a policy names", "judge", name, "err", err)
|
||||||
@@ -114,3 +154,88 @@ func (k *judgeKeeper) check() {
|
|||||||
"judge", name, "repo", kj.Repo, "file", kj.File, "job", job.ID)
|
"judge", name, "repo", kj.Repo, "file", kj.File, "job", job.ID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// refit moves a judge an earlier `server init` installed to what init
|
||||||
|
// installs now, and restarts its replicas onto it. Those judges reserved no
|
||||||
|
// cores or one, 2 or 4 GB, and ran llama.cpp's prompt cache: on fewer than
|
||||||
|
// eight cores every judgment missed its 5 s budget and fail: closed refused
|
||||||
|
// every request, until fixed by hand. A judge whose profile differs in any
|
||||||
|
// other way was set by somebody, and is left as it is, said once.
|
||||||
|
func (k *judgeKeeper) refit(m catalog.Model) {
|
||||||
|
if k.profiles == nil || k.restart == nil || k.replicas == nil || m.Name != defaultJudge || !m.IsGuard() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if k.looked == nil {
|
||||||
|
k.looked = map[string]bool{}
|
||||||
|
}
|
||||||
|
if k.looked[m.Name] {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
k.looked[m.Name] = true
|
||||||
|
if !earlierInitJudge(m) {
|
||||||
|
if len(m.Profiles) != 1 || !slices.Contains(m.Profiles[0].Args, "--cache-ram") {
|
||||||
|
k.log.Info("the judge's profile is not one init wrote; left as it is",
|
||||||
|
"judge", m.Name, "see", "llmgw ctl model resources "+m.Name)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var cpus []int
|
||||||
|
if k.nodeCPUs != nil {
|
||||||
|
cpus = k.nodeCPUs()
|
||||||
|
}
|
||||||
|
p := m.Profiles[0]
|
||||||
|
p.MinMemoryGB = judgeMemoryGB
|
||||||
|
p.CPUCores = refitCores(m.Scaling.Min, cpus)
|
||||||
|
p.Args = []string{"--ctx-size", "8192", "--cache-ram", "0"}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if _, err := k.profiles.SetProfiles(ctx, m.Name, []catalog.Profile{p}, "llmgw (the judge an earlier init installed)"); err != nil {
|
||||||
|
k.log.Warn("could not refit the judge an earlier init installed", "judge", m.Name, "err", err)
|
||||||
|
k.looked[m.Name] = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := k.replicas(m.Name)
|
||||||
|
if len(ids) > 0 {
|
||||||
|
if err := k.restart(ids...); err != nil {
|
||||||
|
k.log.Warn("refitted the judge, but could not restart it", "judge", m.Name, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
k.log.Info("refitted the judge an earlier init installed", "judge", m.Name,
|
||||||
|
"memory_gb", p.MinMemoryGB, "cpu_cores", p.CPUCores, "replicas_restarted", len(ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
// earlierInitJudge is whether a judge's entry is exactly what `server init`
|
||||||
|
// before v0.14.7 wrote: one llama.cpp profile on CPUs, an 8192 context and
|
||||||
|
// no other args, 2 or 4 GB, none to two cores.
|
||||||
|
func earlierInitJudge(m catalog.Model) bool {
|
||||||
|
if len(m.Profiles) != 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p := m.Profiles[0]
|
||||||
|
return p.Name == "cpu" && p.Engine == "llamacpp" && p.GPUClass == "" && p.GPU == nil &&
|
||||||
|
len(p.NodeSelector) == 0 && slices.Equal(p.Args, []string{"--ctx-size", "8192"}) &&
|
||||||
|
(p.MinMemoryGB == 2 || p.MinMemoryGB == 4) && p.CPUCores >= 0 && p.CPUCores <= 2
|
||||||
|
}
|
||||||
|
|
||||||
|
// refitCores is a judge replica's cores by init's rule: up to four for the
|
||||||
|
// one judge of a cluster, two each where there are more, fewer where the
|
||||||
|
// smallest node cannot hold them all.
|
||||||
|
func refitCores(replicas int, cpus []int) int {
|
||||||
|
smallest := 0
|
||||||
|
for _, c := range cpus {
|
||||||
|
if c > 0 && (smallest == 0 || c < smallest) {
|
||||||
|
smallest = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if replicas <= 1 {
|
||||||
|
if smallest == 0 {
|
||||||
|
return judgeCores
|
||||||
|
}
|
||||||
|
return min(smallest, judgeCoresAlone)
|
||||||
|
}
|
||||||
|
cores := judgeCores
|
||||||
|
if len(cpus) == 1 && smallest > 0 && replicas*cores > smallest {
|
||||||
|
cores = max(1, smallest/replicas)
|
||||||
|
}
|
||||||
|
return cores
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -51,7 +52,8 @@ func TestAJudgeAPolicyNamesIsFetchedWhenTheCatalogLacksIt(t *testing.T) {
|
|||||||
// The GPU judge alone: the CPU one is there, and one llmgw does not know
|
// The GPU judge alone: the CPU one is there, and one llmgw does not know
|
||||||
// is not guessed at.
|
// is not guessed at.
|
||||||
if len(fs.started) != 1 || fs.started[0].Name != "qwen3guard-4b" || fs.started[0].GPUClass != "6000-24g" ||
|
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 {
|
fs.started[0].Class != catalog.ClassStandard || fs.started[0].GuardFormat != "qwen3guard" || fs.started[0].CPU ||
|
||||||
|
!fs.started[0].IfAbsent {
|
||||||
t.Fatalf("started %+v", fs.started)
|
t.Fatalf("started %+v", fs.started)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +92,12 @@ func TestAJudgeAPolicyNamesIsFetchedWhenTheCatalogLacksIt(t *testing.T) {
|
|||||||
lib = guard.Config{Policies: map[string]guard.Policy{"b": {Judge: &guard.JudgePolicy{Model: "qwen3guard-0.6b", Input: "block"}}}}
|
lib = guard.Config{Policies: map[string]guard.Policy{"b": {Judge: &guard.JudgePolicy{Model: "qwen3guard-0.6b", Input: "block"}}}}
|
||||||
inCatalog = nil
|
inCatalog = nil
|
||||||
k.gpus = func() []adminapi.ClassUse { return nil }
|
k.gpus = func() []adminapi.ClassUse { return nil }
|
||||||
|
k.nodeCPUs = func() []int { return []int{8, 4} }
|
||||||
k.check()
|
k.check()
|
||||||
if last := fs.started[len(fs.started)-1]; last.Name != "qwen3guard-0.6b" || !last.CPU || last.Replicas != 2 {
|
// Sized as init sizes its two judges: 2 GB, and two cores each on a
|
||||||
|
// cluster whose smallest node has four.
|
||||||
|
if last := fs.started[len(fs.started)-1]; last.Name != "qwen3guard-0.6b" || !last.CPU || last.Replicas != 2 ||
|
||||||
|
last.MemoryGB != judgeMemoryGB || last.CPUCores != refitCores(2, []int{8, 4}) || last.CPUCores == 0 {
|
||||||
t.Errorf("CPU judge: %+v", last)
|
t.Errorf("CPU judge: %+v", last)
|
||||||
}
|
}
|
||||||
fs.err = errors.New("the mirror is down")
|
fs.err = errors.New("the mirror is down")
|
||||||
@@ -124,3 +130,108 @@ func TestCategoriesFollowTheJudges(t *testing.T) {
|
|||||||
t.Errorf("no categories (every one counts) became %v", got)
|
t.Errorf("no categories (every one counts) became %v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type fakeProfiles struct {
|
||||||
|
set map[string][]catalog.Profile
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeProfiles) SetProfiles(_ context.Context, name string, ps []catalog.Profile, _ string) (catalog.Model, error) {
|
||||||
|
if f.set == nil {
|
||||||
|
f.set = map[string][]catalog.Profile{}
|
||||||
|
}
|
||||||
|
f.set[name] = ps
|
||||||
|
return catalog.Model{Name: name, Profiles: ps}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A judge that v0.14.6 or earlier's init installed is moved to today's
|
||||||
|
// figures, once, and its replicas restarted onto them; one somebody changed
|
||||||
|
// is left alone.
|
||||||
|
func TestAnEarlierInstallsJudgeIsRefittedOnce(t *testing.T) {
|
||||||
|
lib := guard.Config{Policies: map[string]guard.Policy{
|
||||||
|
"baseline": {Judge: &guard.JudgePolicy{Model: "qwen3guard-0.6b", Input: "block"}},
|
||||||
|
}}
|
||||||
|
earlier := guardModel("http://10.0.0.5:8081/qwen3guard-0.6b/guard/g.gguf", 2, 1, nil)
|
||||||
|
earlier.Profiles[0].MinMemoryGB, earlier.Profiles[0].Args = 4, []string{"--ctx-size", "8192"}
|
||||||
|
inCatalog := []catalog.Model{earlier}
|
||||||
|
fp := &fakeProfiles{}
|
||||||
|
var restarted []string
|
||||||
|
k := &judgeKeeper{
|
||||||
|
library: func() guard.Config { return lib },
|
||||||
|
models: func() []catalog.Model { return inCatalog },
|
||||||
|
serve: &fakeServe{},
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
profiles: fp,
|
||||||
|
restart: func(ids ...string) error { restarted = append(restarted, ids...); return nil },
|
||||||
|
replicas: func(string) []string { return []string{"j-1", "j-2"} },
|
||||||
|
nodeCPUs: func() []int { return []int{4} },
|
||||||
|
}
|
||||||
|
k.check()
|
||||||
|
got := fp.set["qwen3guard-0.6b"]
|
||||||
|
if len(got) != 1 || got[0].MinMemoryGB != 2 || got[0].CPUCores != 2 || !slices.Contains(got[0].Args, "--cache-ram") {
|
||||||
|
t.Fatalf("refitted to %+v", got)
|
||||||
|
}
|
||||||
|
if !slices.Equal(restarted, []string{"j-1", "j-2"}) {
|
||||||
|
t.Errorf("restarted %v", restarted)
|
||||||
|
}
|
||||||
|
k.check()
|
||||||
|
if len(restarted) != 2 {
|
||||||
|
t.Error("refitted twice")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Changed by somebody: an extra arg. Left as it is.
|
||||||
|
mine := guardModel("http://x/g.gguf", 2, 3, nil)
|
||||||
|
mine.Profiles[0].Args = []string{"--ctx-size", "16384"}
|
||||||
|
inCatalog = []catalog.Model{mine}
|
||||||
|
fp.set, k.looked = nil, nil
|
||||||
|
k.check()
|
||||||
|
if fp.set != nil {
|
||||||
|
t.Errorf("a judge set by hand was changed: %+v", fp.set)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAJudgeIsRefittedToTheCoresInitWouldGive(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
replicas int
|
||||||
|
cpus []int
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{1, []int{4}, 4},
|
||||||
|
{1, []int{2}, 2},
|
||||||
|
{1, []int{16}, 4},
|
||||||
|
{2, []int{4}, 2},
|
||||||
|
{2, []int{2}, 1},
|
||||||
|
{2, []int{4, 32}, 2},
|
||||||
|
{2, nil, 2},
|
||||||
|
} {
|
||||||
|
if got := refitCores(c.replicas, c.cpus); got != c.want {
|
||||||
|
t.Errorf("%d judges on %v: %d cores, want %d", c.replicas, c.cpus, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Just after the control plane starts, the judge init is adding is left to
|
||||||
|
// init: the keeper serves one itself only after its grace.
|
||||||
|
func TestTheKeeperLeavesAJustStartedInstallsJudgeToInit(t *testing.T) {
|
||||||
|
lib := guard.Config{Policies: map[string]guard.Policy{"b": {Judge: &guard.JudgePolicy{Model: "qwen3guard-0.6b", Input: "block"}}}}
|
||||||
|
now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
|
||||||
|
fs := &fakeServe{}
|
||||||
|
k := &judgeKeeper{
|
||||||
|
library: func() guard.Config { return lib },
|
||||||
|
models: func() []catalog.Model { return nil },
|
||||||
|
serve: fs, grace: judgeGrace,
|
||||||
|
gpus: func() []adminapi.ClassUse { return nil },
|
||||||
|
log: slog.New(slog.DiscardHandler),
|
||||||
|
now: func() time.Time { return now },
|
||||||
|
}
|
||||||
|
k.check()
|
||||||
|
now = now.Add(judgeGrace / 2)
|
||||||
|
k.check()
|
||||||
|
if len(fs.started) != 0 {
|
||||||
|
t.Fatalf("served within its grace: %+v", fs.started)
|
||||||
|
}
|
||||||
|
now = now.Add(judgeGrace)
|
||||||
|
k.check()
|
||||||
|
if len(fs.started) != 1 {
|
||||||
|
t.Errorf("never served after its grace: %d", len(fs.started))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -142,7 +142,9 @@ func (k knownJudge) serveRequest(o judgeServeOptions) modelserve.Request {
|
|||||||
}
|
}
|
||||||
return req
|
return req
|
||||||
}
|
}
|
||||||
req.CPU, req.CPUCores = true, o.Cores
|
// Sized as init sizes its judge: the memory it measured to need with
|
||||||
|
// no prompt cache, and the cores its rule gives.
|
||||||
|
req.CPU, req.CPUCores, req.MemoryGB = true, o.Cores, judgeMemoryGB
|
||||||
if req.Replicas == 0 {
|
if req.Replicas == 0 {
|
||||||
req.Replicas = 2
|
req.Replicas = 2
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
@@ -212,6 +216,45 @@ func newKBCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|||||||
def.Flags().BoolVar(&clear, "clear", false, "no default any more")
|
def.Flags().BoolVar(&clear, "clear", false, "no default any more")
|
||||||
c.AddCommand(def)
|
c.AddCommand(def)
|
||||||
|
|
||||||
|
var dsModel, dsOut string
|
||||||
|
var dsPer, dsMax int
|
||||||
|
dataset := &cobra.Command{
|
||||||
|
Use: "dataset COLLECTION --model MODEL",
|
||||||
|
Short: "Write training data from a collection: questions and answers, for an adapter",
|
||||||
|
Long: "A chat model of the cluster reads each chunk of the collection and writes\n" +
|
||||||
|
"questions a person might ask of it, with the answers it holds, one JSON object\n" +
|
||||||
|
"a line ({\"q\", \"a\", \"doc\", \"ord\"}). Read it through and correct it before\n" +
|
||||||
|
"training on it (llmgw ctl train): what the model got wrong, an adapter learns.\n\n" +
|
||||||
|
"An adapter learns how answers sound — the organisation's words, format and\n" +
|
||||||
|
"tone — far better than what they say; the facts stay the knowledge base's.",
|
||||||
|
Example: " llmgw ctl kb dataset Acme/policies --model qwen2.5-coder-14b-instruct --out policies.jsonl",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
w := cmd.OutOrStdout()
|
||||||
|
if dsOut != "" {
|
||||||
|
f, err := os.Create(dsOut)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w = f
|
||||||
|
}
|
||||||
|
lw := &datasetWriter{pairs: w, problems: cmd.ErrOrStderr()}
|
||||||
|
body, _ := json.Marshal(adminapi.KBDatasetRequest{Model: dsModel, PerChunk: dsPer, MaxChunks: dsMax})
|
||||||
|
err := client().patient(6*time.Hour).do(cmd.Context(), "POST",
|
||||||
|
"/api/v1/kb/collections/"+url.PathEscape(args[0])+"/dataset", json.RawMessage(body), lw)
|
||||||
|
lw.flush()
|
||||||
|
fmt.Fprintf(cmd.ErrOrStderr(), "%d pairs from %d chunks; %d chunks gave none\n", lw.n, len(lw.chunks), lw.failed)
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dataset.Flags().StringVar(&dsModel, "model", "", "the chat model that writes the questions and answers")
|
||||||
|
dataset.Flags().StringVar(&dsOut, "out", "", "the file to write (default: standard output)")
|
||||||
|
dataset.Flags().IntVar(&dsPer, "per-chunk", 8, "pairs from each chunk")
|
||||||
|
dataset.Flags().IntVar(&dsMax, "max-chunks", 500, "the most chunks read")
|
||||||
|
_ = dataset.MarkFlagRequired("model")
|
||||||
|
c.AddCommand(dataset)
|
||||||
|
|
||||||
var k int
|
var k int
|
||||||
search := &cobra.Command{
|
search := &cobra.Command{
|
||||||
Use: "search COLLECTION QUERY...",
|
Use: "search COLLECTION QUERY...",
|
||||||
@@ -240,3 +283,50 @@ func newKBCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|||||||
c.AddCommand(search)
|
c.AddCommand(search)
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// datasetWriter splits the dataset stream: pairs to the file, problems to
|
||||||
|
// the terminal, counted as they pass.
|
||||||
|
type datasetWriter struct {
|
||||||
|
pairs, problems io.Writer
|
||||||
|
buf []byte
|
||||||
|
n, failed int
|
||||||
|
chunks map[string]bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *datasetWriter) Write(p []byte) (int, error) {
|
||||||
|
d.buf = append(d.buf, p...)
|
||||||
|
for {
|
||||||
|
i := bytes.IndexByte(d.buf, '\n')
|
||||||
|
if i < 0 {
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
d.line(d.buf[:i])
|
||||||
|
d.buf = d.buf[i+1:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *datasetWriter) flush() {
|
||||||
|
if len(bytes.TrimSpace(d.buf)) > 0 {
|
||||||
|
d.line(d.buf)
|
||||||
|
}
|
||||||
|
d.buf = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *datasetWriter) line(b []byte) {
|
||||||
|
var l adminapi.KBDatasetLine
|
||||||
|
if json.Unmarshal(b, &l) != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if d.chunks == nil {
|
||||||
|
d.chunks = map[string]bool{}
|
||||||
|
}
|
||||||
|
d.chunks[fmt.Sprintf("%s#%d", l.Doc, l.Ord)] = true
|
||||||
|
if l.Error != "" {
|
||||||
|
d.failed++
|
||||||
|
fmt.Fprintf(d.problems, " %s #%d: %s\n", l.Doc, l.Ord, l.Error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.n++
|
||||||
|
out, _ := json.Marshal(l)
|
||||||
|
fmt.Fprintln(d.pairs, string(out))
|
||||||
|
}
|
||||||
+27
-1
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"text/tabwriter"
|
"text/tabwriter"
|
||||||
@@ -301,7 +302,8 @@ func newLimitsCmd(open opener) *cobra.Command {
|
|||||||
var perMember bool
|
var perMember bool
|
||||||
var rpm, tpm int
|
var rpm, tpm int
|
||||||
var budget float64
|
var budget float64
|
||||||
var models []string
|
var models, tools []string
|
||||||
|
var replace bool
|
||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "limits",
|
Use: "limits",
|
||||||
@@ -357,7 +359,19 @@ func newLimitsCmd(open opener) *cobra.Command {
|
|||||||
return errors.New("--per-member is for --scope org, team or project")
|
return errors.New("--per-member is for --scope org, team or project")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Only what is given changes: setting a tool grant must not
|
||||||
|
// clear the scope's rate or budget. --replace writes the row
|
||||||
|
// whole, a flag not given then no limit, as the console's
|
||||||
|
// form does with every field it shows.
|
||||||
l := store.Limits{Scope: level, ScopeID: scopeID}
|
l := store.Limits{Scope: level, ScopeID: scopeID}
|
||||||
|
if !replace {
|
||||||
|
switch cur, err := s.LimitsFor(cmd.Context(), level, scopeID); {
|
||||||
|
case err == nil:
|
||||||
|
l = cur
|
||||||
|
case !errors.Is(err, store.ErrNotFound):
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if cmd.Flags().Changed("rpm") {
|
if cmd.Flags().Changed("rpm") {
|
||||||
l.RPM = &rpm
|
l.RPM = &rpm
|
||||||
}
|
}
|
||||||
@@ -379,6 +393,14 @@ func newLimitsCmd(open opener) *cobra.Command {
|
|||||||
}
|
}
|
||||||
l.AllowedModels = models
|
l.AllowedModels = models
|
||||||
}
|
}
|
||||||
|
if cmd.Flags().Changed("tools") {
|
||||||
|
for _, p := range tools {
|
||||||
|
if _, err := path.Match(p, "x.y"); err != nil {
|
||||||
|
return fmt.Errorf("--tools %q is not a pattern: %w", p, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
l.AllowedTools = tools
|
||||||
|
}
|
||||||
if err := s.PutLimits(cmd.Context(), l); err != nil {
|
if err := s.PutLimits(cmd.Context(), l); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -395,6 +417,10 @@ func newLimitsCmd(open opener) *cobra.Command {
|
|||||||
cmd.Flags().Float64Var(&budget, "budget", 0, "budget per period, in whole units as the quotas page shows them")
|
cmd.Flags().Float64Var(&budget, "budget", 0, "budget per period, in whole units as the quotas page shows them")
|
||||||
cmd.Flags().StringVar(&period, "period", "month", "day | month")
|
cmd.Flags().StringVar(&period, "period", "month", "day | month")
|
||||||
cmd.Flags().StringSliceVar(&models, "models", nil, "allowed models; empty allows none")
|
cmd.Flags().StringSliceVar(&models, "models", nil, "allowed models; empty allows none")
|
||||||
|
cmd.Flags().BoolVar(&replace, "replace", false,
|
||||||
|
"write every limit at the scope: one not given is then no limit there (default: only what is given changes)")
|
||||||
|
cmd.Flags().StringSliceVar(&tools, "tools", nil,
|
||||||
|
"the company MCP servers' tools granted here, as patterns: jira.*,db.query; none granted anywhere is none")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -65,6 +66,14 @@ Safe to run again: it changes only what differs.`,
|
|||||||
}
|
}
|
||||||
if !noFirewall {
|
if !noFirewall {
|
||||||
mirrorFirewall(cmd.Context(), cmd.OutOrStdout(), controlPlanes)
|
mirrorFirewall(cmd.Context(), cmd.OutOrStdout(), controlPlanes)
|
||||||
|
// And after, as the cluster's settings say: the control plane
|
||||||
|
// tells the mirror, which writes it beside its models.
|
||||||
|
state := filepath.Join(*dir, mirror.FirewallFile)
|
||||||
|
if err := installFirewallSync(cmd.Context(), firewall.Local{}, state); err != nil {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(), " !! firewall: %v; the cluster's settings will not change it here\n", err)
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), " ok firewall: follows the cluster's settings ("+firewallSyncPathUnit+")")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
+78
-30
@@ -3,6 +3,7 @@ package cli
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/user"
|
"os/user"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -32,7 +33,7 @@ func newModelCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|||||||
Use: "model",
|
Use: "model",
|
||||||
Short: "Add or remove models while the cluster is running",
|
Short: "Add or remove models while the cluster is running",
|
||||||
}
|
}
|
||||||
c.AddCommand(newModelAddCmd(client), newModelRemoveCmd(client), newModelPlanCmd(client),
|
c.AddCommand(newModelAddCmd(client), newModelPresetsCmd(client, asJSON), newModelRemoveCmd(client), newModelPlanCmd(client),
|
||||||
newResourcesCmd(client), newClassCmd(client), newOwnerCmd(client),
|
newResourcesCmd(client), newClassCmd(client), newOwnerCmd(client),
|
||||||
newTargetCmd(client, asJSON), newAdaptersCmd(client),
|
newTargetCmd(client, asJSON), newAdaptersCmd(client),
|
||||||
newModelServeCmd(client), newModelUpdatesCmd(client, asJSON), newModelUpdateCmd(client),
|
newModelServeCmd(client), newModelUpdatesCmd(client, asJSON), newModelUpdateCmd(client),
|
||||||
@@ -46,49 +47,74 @@ func newModelAddCmd(client func() *ctlClient) *cobra.Command {
|
|||||||
var path string
|
var path string
|
||||||
var skipCheck bool
|
var skipCheck bool
|
||||||
c := &cobra.Command{
|
c := &cobra.Command{
|
||||||
Use: "add -f <file.yaml>",
|
Use: "add PRESET | -f <file.yaml>",
|
||||||
Short: "Add a model from a definition file",
|
Short: "Add a model that comes with llmgw, or one from a definition file",
|
||||||
Long: "The definition is the same YAML a model takes in server.yaml, so an\n" +
|
Long: "A preset is a model that comes with llmgw, its workflow, nodes and licence\n" +
|
||||||
|
"already found and tried: " + strings.Join(catalog.PresetNames(), ", ") + " (llmgw ctl model presets).\n" +
|
||||||
|
"Adding a ComfyUI one also has the nodes install ComfyUI, when the cluster\n" +
|
||||||
|
"has not been told otherwise.\n\n" +
|
||||||
|
"A definition is the same YAML a model takes in server.yaml, so an\n" +
|
||||||
"entry can be moved between the two by copying it.\n\n" +
|
"entry can be moved between the two by copying it.\n\n" +
|
||||||
"The catalog is rebuilt and validated before anything is written, so a\n" +
|
"The catalog is rebuilt and validated before anything is written, so a\n" +
|
||||||
"definition that collides with an existing name, or names a fallback that\n" +
|
"definition that collides with an existing name, or names a fallback that\n" +
|
||||||
"does not exist, is refused rather than stored and then ignored on every\n" +
|
"does not exist, is refused rather than stored and then ignored on every\n" +
|
||||||
"start. Once accepted it takes effect on the reconciler's next pass — no\n" +
|
"start. Once accepted it takes effect on the reconciler's next pass — no\n" +
|
||||||
"restart — and survives one.",
|
"restart — and survives one.",
|
||||||
Args: cobra.NoArgs,
|
Example: " llmgw ctl model add triposr\n" +
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
" llmgw ctl model add -f qwen.yaml",
|
||||||
if path == "" {
|
Args: cobra.MaximumNArgs(1),
|
||||||
return fmt.Errorf("--file is required: the model definition, as YAML")
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
}
|
|
||||||
// Read by koanf, the same parser and the same tags that read
|
|
||||||
// server.yaml — so an entry can be moved between the two by copying
|
|
||||||
// it, and a field that works in one cannot silently mean nothing in
|
|
||||||
// the other.
|
|
||||||
k := koanf.New(".")
|
|
||||||
if err := k.Load(file.Provider(path), yaml.Parser()); err != nil {
|
|
||||||
return fmt.Errorf("%s: %w", path, err)
|
|
||||||
}
|
|
||||||
var m catalog.Model
|
var m catalog.Model
|
||||||
if err := k.UnmarshalWithConf("", &m, koanf.UnmarshalConf{Tag: "koanf"}); err != nil {
|
switch {
|
||||||
return fmt.Errorf("%s: %w", path, err)
|
case len(args) == 1 && path != "":
|
||||||
}
|
return fmt.Errorf("a preset or a file, not both")
|
||||||
if m.Name == "" {
|
case len(args) == 1:
|
||||||
return fmt.Errorf("%s: the definition has no name", path)
|
p, ok := catalog.PresetNamed(args[0])
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("no preset %q: there are %s; any other model is added from a file (-f)",
|
||||||
|
args[0], strings.Join(catalog.PresetNames(), ", "))
|
||||||
|
}
|
||||||
|
// The control plane adds it, and keeps that it was added as
|
||||||
|
// a preset, by whom; one from before it could is sent the
|
||||||
|
// entry, as it was then.
|
||||||
|
var res adminapi.ModelAdded
|
||||||
|
err := client().post(cmd.Context(), "/api/v1/presets/"+url.PathEscape(p.Model.Name), []byte("{}"), &res)
|
||||||
|
if err == nil {
|
||||||
|
fmt.Fprintf(cmd.OutOrStdout(),
|
||||||
|
"%s added, and ComfyUI with it where the cluster had none. It is placed on the next\n"+
|
||||||
|
"reconcile pass and loads when first asked for; watch it with:\n llmgw ctl models\n", p.Model.Name)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !olderControlPlane(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m = p.Model
|
||||||
|
case path == "":
|
||||||
|
return fmt.Errorf("a preset (%s) or --file, the model definition as YAML",
|
||||||
|
strings.Join(catalog.PresetNames(), ", "))
|
||||||
|
default:
|
||||||
|
var err error
|
||||||
|
if m, err = readModelFile(path); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before the catalog is touched, because a source that does not
|
// Before the catalog is touched, because a source that does not
|
||||||
// resolve is otherwise discovered by a replica failing to start
|
// resolve is otherwise discovered by a replica failing to start
|
||||||
// minutes later on a node — with an error naming the node rather
|
// minutes later on a node — with an error naming the node rather
|
||||||
// than the typo.
|
// than the typo.
|
||||||
|
// Every source the model's profiles load, not the model's alone.
|
||||||
if !skipCheck {
|
if !skipCheck {
|
||||||
size, err := sourcecheck.Check(cmd.Context(), m.Source)
|
for _, src := range m.WeightSources() {
|
||||||
if err != nil {
|
size, err := sourcecheck.Check(cmd.Context(), src)
|
||||||
return fmt.Errorf("%w\n Pass --no-source-check to add it anyway, "+
|
if err != nil {
|
||||||
"which is right when the mirror is not up yet or the nodes "+
|
return fmt.Errorf("%w\n Pass --no-source-check to add it anyway, "+
|
||||||
"reach it by an address this machine does not.", err)
|
"which is right when the mirror is not up yet or the nodes "+
|
||||||
}
|
"reach it by an address this machine does not.", err)
|
||||||
if _, isURL := sourcecheck.HTTPSource(m.Source); isURL {
|
}
|
||||||
fmt.Fprintln(cmd.OutOrStdout(), sourceNote(m.Source, size))
|
if _, isURL := sourcecheck.HTTPSource(src); isURL {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), sourceNote(src, size))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +130,9 @@ func newModelAddCmd(client func() *ctlClient) *cobra.Command {
|
|||||||
fmt.Fprintf(cmd.OutOrStdout(),
|
fmt.Fprintf(cmd.OutOrStdout(),
|
||||||
"%s added. It will be placed on the next reconcile pass; watch it with:\n"+
|
"%s added. It will be placed on the next reconcile pass; watch it with:\n"+
|
||||||
" llmgw ctl models\n", m.Name)
|
" llmgw ctl models\n", m.Name)
|
||||||
|
if m.ServesComfyUI() && !m.IsExternal() {
|
||||||
|
return ensureComfyUI(cmd, client, m.Name)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -113,6 +142,25 @@ func newModelAddCmd(client func() *ctlClient) *cobra.Command {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readModelFile reads a model's definition. By koanf, the same parser and
|
||||||
|
// the same tags that read server.yaml — so an entry can be moved between the
|
||||||
|
// two by copying it, and a field that works in one cannot silently mean
|
||||||
|
// nothing in the other.
|
||||||
|
func readModelFile(path string) (catalog.Model, error) {
|
||||||
|
var m catalog.Model
|
||||||
|
k := koanf.New(".")
|
||||||
|
if err := k.Load(file.Provider(path), yaml.Parser()); err != nil {
|
||||||
|
return m, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
if err := k.UnmarshalWithConf("", &m, koanf.UnmarshalConf{Tag: "koanf"}); err != nil {
|
||||||
|
return m, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
if m.Name == "" {
|
||||||
|
return m, fmt.Errorf("%s: the definition has no name", path)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
func newModelRemoveCmd(client func() *ctlClient) *cobra.Command {
|
func newModelRemoveCmd(client func() *ctlClient) *cobra.Command {
|
||||||
return &cobra.Command{
|
return &cobra.Command{
|
||||||
Use: "remove <name>",
|
Use: "remove <name>",
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newModelPresetsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||||
|
c := &cobra.Command{
|
||||||
|
Use: "presets",
|
||||||
|
Short: "The models that come with llmgw: whether each fits and is here",
|
||||||
|
Long: "Each is a catalog entry whose workflow, custom nodes, packages and licence\n" +
|
||||||
|
"were found and tried, and whose profile says the card it needs rather than\n" +
|
||||||
|
"a class, so it places on any site's cards. Add one with\n" +
|
||||||
|
"`llmgw ctl model add NAME`.\n\n" +
|
||||||
|
"While presets.auto is on, the leader adds one by itself when a card fits it\n" +
|
||||||
|
"and the catalog has nothing that makes what it makes; one somebody removed\n" +
|
||||||
|
"is not added again by itself.",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
|
var v adminapi.PresetsView
|
||||||
|
err := client().get(cmd.Context(), "/api/v1/presets", &v)
|
||||||
|
if err != nil && !olderControlPlane(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if *asJSON && err == nil {
|
||||||
|
return emitJSON(v)
|
||||||
|
}
|
||||||
|
w := tabTo(cmd.OutOrStdout())
|
||||||
|
if err != nil {
|
||||||
|
// A control plane from before it kept them: what this
|
||||||
|
// release has, with nothing about the cluster.
|
||||||
|
fmt.Fprintln(w, "NAME\tMAKES\tCARD\tLICENCE\tWHAT IT IS FOR")
|
||||||
|
for _, p := range catalog.Presets() {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", p.Model.Name, p.Makes, presetCard(p.Model),
|
||||||
|
presetLicence(p.Model), p.Model.Description)
|
||||||
|
}
|
||||||
|
return w.Flush()
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "NAME\tMAKES\tCARD\tLICENCE\tFITS\tHERE\tWHAT IT IS FOR")
|
||||||
|
for _, p := range v.Presets {
|
||||||
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Makes, p.Card, licenceText(p.Licence),
|
||||||
|
orDash(strings.Join(p.FitsClasses, ",")), presetState(p), p.Description)
|
||||||
|
}
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v.Auto {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "\nAdded by themselves where a card fits (presets.auto); "+
|
||||||
|
"llmgw ctl model presets auto off stops it.")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "\nNot added by themselves; llmgw ctl model presets auto on starts it.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.AddCommand(&cobra.Command{
|
||||||
|
Use: "auto on|off",
|
||||||
|
Short: "Whether the leader adds a preset by itself where a card fits it",
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
ValidArgs: []string{"on", "off"},
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if args[0] != "on" && args[0] != "off" {
|
||||||
|
return fmt.Errorf("on or off, not %q", args[0])
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(adminapi.PresetsAutoRequest{On: args[0] == "on"})
|
||||||
|
var v adminapi.PresetsView
|
||||||
|
if err := client().put(cmd.Context(), "/api/v1/presets/auto", body, &v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if v.Auto {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "Presets are added by themselves where a card fits.")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(cmd.OutOrStdout(), "Presets are no longer added by themselves.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
// olderControlPlane is an error from a control plane that has no such path:
|
||||||
|
// one from before it was added.
|
||||||
|
func olderControlPlane(err error) bool {
|
||||||
|
s := err.Error()
|
||||||
|
return strings.Contains(s, "answered with a page") || strings.HasPrefix(s, "404 Not Found: 404 page not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
func presetState(p adminapi.PresetView) string {
|
||||||
|
switch {
|
||||||
|
case p.InCatalog && p.AddedBy == "auto":
|
||||||
|
return "added by itself"
|
||||||
|
case p.InCatalog && p.AddedBy != "":
|
||||||
|
return "added by " + p.AddedBy
|
||||||
|
case p.InCatalog:
|
||||||
|
return "here"
|
||||||
|
case p.Declined:
|
||||||
|
return "removed; stays out"
|
||||||
|
}
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
|
||||||
|
func licenceText(l catalog.License) string {
|
||||||
|
if l.Commercial != nil && !*l.Commercial {
|
||||||
|
return l.Name + ", not commercial"
|
||||||
|
}
|
||||||
|
return l.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
func presetCard(m catalog.Model) string {
|
||||||
|
if len(m.Profiles) == 0 || m.Profiles[0].GPU == nil {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return m.Profiles[0].GPU.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func presetLicence(m catalog.Model) string {
|
||||||
|
if m.License == nil {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
l := m.License.Name
|
||||||
|
if c := m.License.Commercial; c != nil && !*c {
|
||||||
|
l += ", not commercial"
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureComfyUI has the nodes install ComfyUI for a model that needs it,
|
||||||
|
// unless the cluster names a version already, or none on purpose.
|
||||||
|
func ensureComfyUI(cmd *cobra.Command, client func() *ctlClient, model string) error {
|
||||||
|
out := cmd.OutOrStdout()
|
||||||
|
path := []string{"engines", catalog.EngineComfyUI}
|
||||||
|
var cur adminapi.SettingsView
|
||||||
|
if err := client().get(cmd.Context(), "/api/v1/settings", &cur); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
doc, err := settings.FromBytes("settings", []byte(cur.Body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
version := provision.ComfyUIVersion()
|
||||||
|
if n := doc.Node(path); n != nil {
|
||||||
|
if n.Value == "" {
|
||||||
|
fmt.Fprintf(out, "engines.comfyui is set to none, so the nodes install no ComfyUI for it: set a version "+
|
||||||
|
"(llmgw ctl config set engines.comfyui %s) or name one installed by hand in agent.yaml\n", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, "Nodes with an NVIDIA GPU install ComfyUI %s for it, about 10 GB each:\n", version)
|
||||||
|
if err := doc.SetYAML(path, version); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, err := doc.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return putSettings(cmd, client, cur, b, "ComfyUI for "+model)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// presetAdmin is an admin API holding settings, which records the models
|
||||||
|
// added and the settings put.
|
||||||
|
type presetAdmin struct {
|
||||||
|
// api is a control plane that adds presets itself; without it, one
|
||||||
|
// from before, which answers their paths with a 404.
|
||||||
|
api bool
|
||||||
|
byAPI []string
|
||||||
|
mu sync.Mutex
|
||||||
|
settings string
|
||||||
|
added []catalog.Model
|
||||||
|
put []string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *presetAdmin) client(t *testing.T) func() *ctlClient {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
a.mu.Lock()
|
||||||
|
defer a.mu.Unlock()
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
switch {
|
||||||
|
case a.api && r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/presets/"):
|
||||||
|
name := strings.TrimPrefix(r.URL.Path, "/api/v1/presets/")
|
||||||
|
a.byAPI = append(a.byAPI, name)
|
||||||
|
_ = json.NewEncoder(w).Encode(adminapi.ModelAdded{Added: name})
|
||||||
|
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/models":
|
||||||
|
var m catalog.Model
|
||||||
|
_ = json.Unmarshal(body, &m)
|
||||||
|
a.added = append(a.added, m)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"name": m.Name})
|
||||||
|
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/settings":
|
||||||
|
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 1, Body: a.settings})
|
||||||
|
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/settings":
|
||||||
|
var req adminapi.SettingsRequest
|
||||||
|
_ = json.Unmarshal(body, &req)
|
||||||
|
a.put = append(a.put, req.Body)
|
||||||
|
a.settings = req.Body
|
||||||
|
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 2, Body: req.Body})
|
||||||
|
default:
|
||||||
|
http.NotFound(w, r)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
return func() *ctlClient { return newCtlClient(strings.TrimPrefix(srv.URL, "http://"), "") }
|
||||||
|
}
|
||||||
|
|
||||||
|
func runModelAdd(t *testing.T, client func() *ctlClient, args ...string) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
cmd := newModelAddCmd(client)
|
||||||
|
var out bytes.Buffer
|
||||||
|
cmd.SetOut(&out)
|
||||||
|
cmd.SetErr(&out)
|
||||||
|
cmd.SetArgs(append(args, "--no-source-check"))
|
||||||
|
err := cmd.ExecuteContext(t.Context())
|
||||||
|
return out.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPresetIsAddedByTheControlPlane(t *testing.T) {
|
||||||
|
a := &presetAdmin{api: true}
|
||||||
|
out, err := runModelAdd(t, a.client(t), "triposr")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if len(a.byAPI) != 1 || a.byAPI[0] != "triposr" || len(a.added) != 0 || len(a.put) != 0 {
|
||||||
|
t.Errorf("by the API %v; sent the entry %v; settings %v", a.byAPI, a.added, a.put)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A control plane from before presets were its own is sent the entry, and
|
||||||
|
// ComfyUI set beside it, as the release that brought presets did.
|
||||||
|
func TestAPresetIsAddedByNameAndHasTheNodesInstallComfyUI(t *testing.T) {
|
||||||
|
a := &presetAdmin{settings: "router:\n retries: 2\n"}
|
||||||
|
out, err := runModelAdd(t, a.client(t), "triposr")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if len(a.added) != 1 || a.added[0].Name != "triposr" || a.added[0].ComfyUI.Workflow != "triposr" {
|
||||||
|
t.Fatalf("added %+v", a.added)
|
||||||
|
}
|
||||||
|
if len(a.put) != 1 || !strings.Contains(a.put[0], "comfyui: 0.39.2") || !strings.Contains(a.put[0], "retries: 2") {
|
||||||
|
t.Fatalf("settings put %q", a.put)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out, "install ComfyUI 0.39.2") {
|
||||||
|
t.Errorf("not said:\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAClusterThatNamesItsComfyUIKeepsIt(t *testing.T) {
|
||||||
|
for _, settings := range []string{"engines:\n comfyui: 0.38.0\n", "engines:\n comfyui: \"\"\n"} {
|
||||||
|
a := &presetAdmin{settings: settings}
|
||||||
|
out, err := runModelAdd(t, a.client(t), "flux-schnell")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if len(a.put) != 0 {
|
||||||
|
t.Errorf("%q: settings changed to %q", settings, a.put)
|
||||||
|
}
|
||||||
|
if none := strings.Contains(settings, `""`); none != strings.Contains(out, "set to none") {
|
||||||
|
t.Errorf("%q: %s", settings, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAnUnknownPresetNamesTheOnesThereAre(t *testing.T) {
|
||||||
|
a := &presetAdmin{}
|
||||||
|
_, err := runModelAdd(t, a.client(t), "sdxl")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "flux-schnell, triposr") || len(a.added) != 0 {
|
||||||
|
t.Errorf("%v, added %v", err, a.added)
|
||||||
|
}
|
||||||
|
if _, err := runModelAdd(t, a.client(t), "triposr", "-f", "x.yaml"); err == nil {
|
||||||
|
t.Error("a preset and a file were both taken")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,6 +60,8 @@ func newModelServeCmd(client func() *ctlClient) *cobra.Command {
|
|||||||
f.StringVar(&req.Quant, "quant", "", "the quantisation to serve (default: the one that suits the cluster best)")
|
f.StringVar(&req.Quant, "quant", "", "the quantisation to serve (default: the one that suits the cluster best)")
|
||||||
f.StringVar(&req.File, "file", "", "the file to serve, instead of choosing by --quant")
|
f.StringVar(&req.File, "file", "", "the file to serve, instead of choosing by --quant")
|
||||||
f.StringVar(&req.Projector, "projector", "", "a vision model's mmproj file, fetched and loaded beside it")
|
f.StringVar(&req.Projector, "projector", "", "a vision model's mmproj file, fetched and loaded beside it")
|
||||||
|
f.BoolVar(&req.Embeddings, "embeddings", false, "serve it as an embedding model (its GGUF header says so for most): "+
|
||||||
|
"--embedding, 32 slots of 1024 tokens unless --ctx-pool or --context-length says otherwise")
|
||||||
f.StringVar(&req.Format, "format", "gguf", "gguf, run by llama.cpp, or safetensors: the original weights, run by vLLM on GPUs")
|
f.StringVar(&req.Format, "format", "gguf", "gguf, run by llama.cpp, or safetensors: the original weights, run by vLLM on GPUs")
|
||||||
f.BoolVar(&req.CPU, "cpu", false, "run it on the CPU, whatever the plan says")
|
f.BoolVar(&req.CPU, "cpu", false, "run it on the CPU, whatever the plan says")
|
||||||
f.StringVar(&req.GPUClass, "gpu-class", "", "run it on GPUs of this class, whatever the plan says")
|
f.StringVar(&req.GPUClass, "gpu-class", "", "run it on GPUs of this class, whatever the plan says")
|
||||||
@@ -67,6 +69,8 @@ func newModelServeCmd(client func() *ctlClient) *cobra.Command {
|
|||||||
f.IntVar(&req.Nodes, "nodes", 0, "with --gpu-class: the nodes one replica spans, over a fabric (vLLM only)")
|
f.IntVar(&req.Nodes, "nodes", 0, "with --gpu-class: the nodes one replica spans, over a fabric (vLLM only)")
|
||||||
f.IntVar(&req.TP, "tp", 0, "the older name of --gpus")
|
f.IntVar(&req.TP, "tp", 0, "the older name of --gpus")
|
||||||
_ = f.MarkHidden("tp")
|
_ = f.MarkHidden("tp")
|
||||||
|
f.StringToStringVar(&req.Env, "env", nil,
|
||||||
|
"KEY=VALUE in the engine's environment on every node, e.g. NCCL_DEBUG=INFO; repeatable")
|
||||||
f.BoolVar(&req.Fallbacks, "fallbacks", false, "also add the next best ways to run it, used when the first is busy")
|
f.BoolVar(&req.Fallbacks, "fallbacks", false, "also add the next best ways to run it, used when the first is busy")
|
||||||
f.BoolVar(&detach, "detach", false, "start it and return: it runs on the control plane; `llmgw ctl jobs` follows it")
|
f.BoolVar(&detach, "detach", false, "start it and return: it runs on the control plane; `llmgw ctl jobs` follows it")
|
||||||
f.StringVar(&req.Name, "name", "", "the catalog name (default: from the repository)")
|
f.StringVar(&req.Name, "name", "", "the catalog name (default: from the repository)")
|
||||||
|
|||||||
@@ -291,14 +291,14 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
|||||||
}
|
}
|
||||||
fmt.Fprintln(out)
|
fmt.Fprintln(out)
|
||||||
w := tabTo(out)
|
w := tabTo(out)
|
||||||
fmt.Fprintln(w, "FILE\tSIZE\tBEST WAY\tHOLDS\tOF\tNOW")
|
fmt.Fprintln(w, "FILE\tSIZE\tBEST WAY\tA CARD\tOF\tALL CARDS\tNOW")
|
||||||
for _, a := range p.Artifacts {
|
for _, a := range p.Artifacts {
|
||||||
name := a.Name
|
name := a.Name
|
||||||
if a.Shards > 1 && a.Format == "gguf" {
|
if a.Shards > 1 && a.Format == "gguf" {
|
||||||
name += fmt.Sprintf(" (%d parts)", a.Shards)
|
name += fmt.Sprintf(" (%d parts)", a.Shards)
|
||||||
}
|
}
|
||||||
if a.Best == nil {
|
if a.Best == nil {
|
||||||
fmt.Fprintf(w, "%s\t%s\tfits nowhere\t\t\t\n", name, bytesShort(a.Bytes))
|
fmt.Fprintf(w, "%s\t%s\tfits nowhere\t\t\t\t\n", name, bytesShort(a.Bytes))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
b := a.Best
|
b := a.Best
|
||||||
@@ -306,11 +306,11 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
|||||||
if b.FreeNow {
|
if b.FreeNow {
|
||||||
now = "free"
|
now = "free"
|
||||||
}
|
}
|
||||||
holds := fmt.Sprintf("%.1f GB a card", b.NeedGB)
|
holds, all := fmt.Sprintf("%.1f GB", b.NeedGB), fmt.Sprintf("%.0f GB", b.TotalGB())
|
||||||
if b.CPU {
|
if b.CPU {
|
||||||
holds = fmt.Sprintf("%.1f GB of RAM", b.NeedGB)
|
holds, all = fmt.Sprintf("%.1f GB of RAM", b.NeedGB), "-"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%.0f GB\t%s\n", name, bytesShort(a.Bytes), b.Label(), holds, b.HaveGB, now)
|
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%.0f GB\t%s\t%s\n", name, bytesShort(a.Bytes), b.Label(), holds, b.HaveGB, all, now)
|
||||||
}
|
}
|
||||||
_ = w.Flush()
|
_ = w.Flush()
|
||||||
rec, ok := p.Recommended()
|
rec, ok := p.Recommended()
|
||||||
@@ -318,7 +318,11 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
|||||||
fmt.Fprintln(out, "\nNo file fits this cluster as it is: a smaller context (--context), or more memory.")
|
fmt.Fprintln(out, "\nNo file fits this cluster as it is: a smaller context (--context), or more memory.")
|
||||||
return errDoesNotFit
|
return errDoesNotFit
|
||||||
}
|
}
|
||||||
fmt.Fprintf(out, "\nBest: %s on %s.\n", rec.Artifact, rec.Label())
|
fmt.Fprintf(out, "\nBest: %s on %s: %s.\n", rec.Artifact, rec.Label(), rec.MemoryText())
|
||||||
|
if p.KVBytesPerToken > 0 {
|
||||||
|
fmt.Fprintf(out, "The context costs %.2f GB per 1000 tokens, the whole model (--context to plan another).\n",
|
||||||
|
modelplan.GB(p.KVBytesPerToken*1000))
|
||||||
|
}
|
||||||
if len(p.Options) > 1 {
|
if len(p.Options) > 1 {
|
||||||
fmt.Fprintln(out, "\nEvery way, best first:")
|
fmt.Fprintln(out, "\nEvery way, best first:")
|
||||||
w = tabTo(out)
|
w = tabTo(out)
|
||||||
@@ -327,7 +331,7 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
|||||||
if o.FreeNow {
|
if o.FreeNow {
|
||||||
now = "free"
|
now = "free"
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " %s\t%s\t%.1f of %.0f GB\t%d host(s)\t%s\n", o.Artifact, o.Label(), o.NeedGB, o.HaveGB, o.Hosts, now)
|
fmt.Fprintf(w, " %s\t%s\t%s\t%d host(s)\t%s\n", o.Artifact, o.Label(), o.MemoryText(), o.Hosts, now)
|
||||||
}
|
}
|
||||||
_ = w.Flush()
|
_ = w.Flush()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,3 +131,33 @@ func TestKeyBudgetsAreWholeUnits(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A limit given alone changes that limit alone: granting a tool keeps the
|
||||||
|
// scope's rate. --replace writes the row whole, as the console's form does.
|
||||||
|
func TestALimitGivenAloneLeavesTheOthersAtTheScope(t *testing.T) {
|
||||||
|
db := filepath.Join(t.TempDir(), "s.db")
|
||||||
|
mustRun(t, db, "org", "add", "org", "Acme")
|
||||||
|
mustRun(t, db, "org", "add", "team", "platform")
|
||||||
|
mustRun(t, db, "org", "add", "project", "gateway")
|
||||||
|
mustRun(t, db, "keys", "limits", "--scope", "project", "--id", "gateway", "--rpm", "30")
|
||||||
|
mustRun(t, db, "keys", "limits", "--scope", "project", "--id", "gateway", "--tools", "jira.*")
|
||||||
|
read := func() store.Limits {
|
||||||
|
s, err := store.Open(context.Background(), db)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer s.Close()
|
||||||
|
all, _ := s.AllLimits(context.Background())
|
||||||
|
for _, l := range all {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
return store.Limits{}
|
||||||
|
}
|
||||||
|
if l := read(); l.RPM == nil || *l.RPM != 30 || len(l.AllowedTools) != 1 {
|
||||||
|
t.Errorf("after a tool grant: rpm %v, tools %v", l.RPM, l.AllowedTools)
|
||||||
|
}
|
||||||
|
mustRun(t, db, "keys", "limits", "--replace", "--scope", "project", "--id", "gateway", "--tpm", "1000")
|
||||||
|
if l := read(); l.RPM != nil || l.AllowedTools != nil || l.TPM == nil {
|
||||||
|
t.Errorf("after --replace: %+v", l)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,7 @@ func Root() *cobra.Command {
|
|||||||
newMirrorCmd(),
|
newMirrorCmd(),
|
||||||
newBundleCmd(),
|
newBundleCmd(),
|
||||||
newSimEngineCmd(),
|
newSimEngineCmd(),
|
||||||
|
newComfyUIGateCmd(),
|
||||||
newDoctorCmd(),
|
newDoctorCmd(),
|
||||||
newNodeCmd(),
|
newNodeCmd(),
|
||||||
newSelfUpgradeCmd(),
|
newSelfUpgradeCmd(),
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -70,6 +73,9 @@ func newSelfUpgradeCmd() *cobra.Command {
|
|||||||
if strings.TrimSpace(string(b)) == "active" {
|
if strings.TrimSpace(string(b)) == "active" {
|
||||||
if steady++; steady >= 3 {
|
if steady++; steady >= 3 {
|
||||||
fmt.Fprintf(out, "%s restarted and running\n", service)
|
fmt.Fprintf(out, "%s restarted and running\n", service)
|
||||||
|
if service == "llmgw-server" {
|
||||||
|
nodesLeftBehind(cmd.Context(), out, newCtlClient("127.0.0.1:9090"), v)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -85,3 +91,25 @@ func newSelfUpgradeCmd() *cobra.Command {
|
|||||||
c.Flags().StringVar(&service, "service", "", "the systemd unit to restart afterwards, e.g. llmgw-mirror")
|
c.Flags().StringVar(&service, "service", "", "the systemd unit to restart afterwards, e.g. llmgw-mirror")
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nodesLeftBehind says, after a control plane alone was upgraded, that its
|
||||||
|
// nodes were not, and how they are: this command looks like the cluster's
|
||||||
|
// upgrade and is not, and a site's nodes stayed a release behind after it.
|
||||||
|
func nodesLeftBehind(ctx context.Context, out io.Writer, c *ctlClient, v string) {
|
||||||
|
var nodes []adminapi.NodeView
|
||||||
|
_ = c.patient(20*time.Second).get(ctx, "/api/v1/nodes", &nodes)
|
||||||
|
behind := 0
|
||||||
|
for _, n := range nodes {
|
||||||
|
if n.AgentVersion != v {
|
||||||
|
behind++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(nodes) == 0:
|
||||||
|
fmt.Fprintf(out, "This upgraded this machine only. The nodes are upgraded from the leading "+
|
||||||
|
"control plane with:\n sudo llmgw ctl upgrade %s --skip-control-plane\n", v)
|
||||||
|
case behind > 0:
|
||||||
|
fmt.Fprintf(out, "This upgraded this machine only: %d of %d node(s) are not on %s. On the leading "+
|
||||||
|
"control plane, run:\n sudo llmgw ctl upgrade %s --skip-control-plane\n", behind, len(nodes), v, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
+137
-25
@@ -49,6 +49,7 @@ import (
|
|||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/fleetmetrics"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/fleetmetrics"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelsvc"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/train"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/kvevents"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/kvevents"
|
||||||
@@ -75,6 +76,7 @@ func newServerCmd() *cobra.Command {
|
|||||||
|
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "server",
|
Use: "server",
|
||||||
|
Args: cobra.NoArgs,
|
||||||
Short: "Run the router (phase 1; the control plane arrives in phase 3)",
|
Short: "Run the router (phase 1; the control plane arrives in phase 3)",
|
||||||
Long: "Serves the OpenAI API in front of a set of engine replicas.\n\n" +
|
Long: "Serves the OpenAI API in front of a set of engine replicas.\n\n" +
|
||||||
"In this phase the replica list is static: give it in the config file, or\n" +
|
"In this phase the replica list is static: give it in the config file, or\n" +
|
||||||
@@ -137,6 +139,26 @@ func newServerCmd() *cobra.Command {
|
|||||||
_ = shutdownTracing(sctx)
|
_ = shutdownTracing(sctx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
// The ports before anything else: a second server on this machine,
|
||||||
|
// whose ports are taken, must stop here, not go on to compete for
|
||||||
|
// the lease and lead a cluster no request can reach.
|
||||||
|
apiLn, err := net.Listen("tcp", cfg.Listen)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listening on %s: %w", cfg.Listen, err)
|
||||||
|
}
|
||||||
|
if cfg.MaxConnections > 0 {
|
||||||
|
// Past this, new connections wait in the kernel's backlog
|
||||||
|
// rather than each taking a goroutine and its buffers.
|
||||||
|
apiLn = netutil.LimitListener(apiLn, cfg.MaxConnections)
|
||||||
|
}
|
||||||
|
adminLn, err := net.Listen("tcp", cfg.AdminListen)
|
||||||
|
if err != nil {
|
||||||
|
_ = apiLn.Close()
|
||||||
|
return fmt.Errorf("listening on %s: %w", cfg.AdminListen, err)
|
||||||
|
}
|
||||||
|
defer apiLn.Close()
|
||||||
|
defer adminLn.Close()
|
||||||
|
|
||||||
rt, err := router.New(cfg, log)
|
rt, err := router.New(cfg, log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -209,6 +231,10 @@ func newServerCmd() *cobra.Command {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if st := rt.Store(); st != nil {
|
||||||
|
// Each control plane raises the same events; one sends each.
|
||||||
|
notifier.SetClaimer(st)
|
||||||
|
}
|
||||||
rt.SetNotifier(notifier)
|
rt.SetNotifier(notifier)
|
||||||
if gate != nil && notifier != nil {
|
if gate != nil && notifier != nil {
|
||||||
gate.SetJudgeAlarm(func(model string, err error) {
|
gate.SetJudgeAlarm(func(model string, err error) {
|
||||||
@@ -232,6 +258,9 @@ func newServerCmd() *cobra.Command {
|
|||||||
var agentHandler http.Handler
|
var agentHandler http.Handler
|
||||||
var agentPath string
|
var agentPath string
|
||||||
var admin *control.Controller
|
var admin *control.Controller
|
||||||
|
// What the settings say of the firewall, for the mirror and the
|
||||||
|
// store's members; nil without a control plane.
|
||||||
|
var fwPub *firewallPublisher
|
||||||
updateMetrics := &modelserve.UpdateCollector{}
|
updateMetrics := &modelserve.UpdateCollector{}
|
||||||
// The store's cluster, when it is one; read by the metrics and
|
// The store's cluster, when it is one; read by the metrics and
|
||||||
// by `ctl status`.
|
// by `ctl status`.
|
||||||
@@ -256,6 +285,7 @@ func newServerCmd() *cobra.Command {
|
|||||||
agentSvc = svc
|
agentSvc = svc
|
||||||
// Stopped by the operators (llmgw stop): nothing placed.
|
// Stopped by the operators (llmgw stop): nothing placed.
|
||||||
ctl.Reconciler().SetStopped(cfg.Serving.Stopped)
|
ctl.Reconciler().SetStopped(cfg.Serving.Stopped)
|
||||||
|
ctl.Reconciler().SetEvictionTimes(cfg.OnDemand.EvictAfterIdle, cfg.OnDemand.MinLifetime)
|
||||||
// Whether nodes keep llmgw's firewall, as the settings say now.
|
// Whether nodes keep llmgw's firewall, as the settings say now.
|
||||||
svc.SetFirewall(func() *llmgwv1.Firewall {
|
svc.SetFirewall(func() *llmgwv1.Firewall {
|
||||||
c := current.Load()
|
c := current.Load()
|
||||||
@@ -275,6 +305,19 @@ func newServerCmd() *cobra.Command {
|
|||||||
ctl.Reconciler().SetNotifier(notifier)
|
ctl.Reconciler().SetNotifier(notifier)
|
||||||
admin = ctl
|
admin = ctl
|
||||||
agentPath, agentHandler = svc.Handler()
|
agentPath, agentHandler = svc.Handler()
|
||||||
|
fwPub = newFirewallPublisher(func() config.Server { return *current.Load() }, rt.Store(),
|
||||||
|
func() []string {
|
||||||
|
var out []string
|
||||||
|
for _, n := range ctl.Inventory().Nodes() {
|
||||||
|
out = append(out, n.MgmtAddr)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
},
|
||||||
|
func() bool { return ctl.Leadership() == nil || ctl.Leadership().IsLeader() }, log)
|
||||||
|
go fwPub.run(ctx, 5*time.Minute)
|
||||||
|
if l := ctl.Leadership(); l != nil {
|
||||||
|
l.OnLead(fwPub.Kick)
|
||||||
|
}
|
||||||
|
|
||||||
// Before AdminHandler, which is where the registry is built —
|
// Before AdminHandler, which is where the registry is built —
|
||||||
// registering after it silently publishes nothing, which is
|
// registering after it silently publishes nothing, which is
|
||||||
@@ -387,8 +430,18 @@ func newServerCmd() *cobra.Command {
|
|||||||
// Knowledge bases, with the cluster's own models to embed a
|
// Knowledge bases, with the cluster's own models to embed a
|
||||||
// probe (a collection's dimension) and an operator's search.
|
// probe (a collection's dimension) and an operator's search.
|
||||||
api.SetKB(&adminapi.KBAdmin{Store: st, Embed: embedder(rt.Replay)})
|
api.SetKB(&adminapi.KBAdmin{Store: st, Embed: embedder(rt.Replay)})
|
||||||
|
// LoRA adapters trained on the nodes.
|
||||||
|
if agentSvc != nil {
|
||||||
|
trainer := train.New(st, agentSvc, log)
|
||||||
|
agentSvc.SetTrain(trainer)
|
||||||
|
go trainer.Run(ctx)
|
||||||
|
api.SetTrain(&adminapi.TrainAdmin{Store: st, Service: trainer})
|
||||||
|
}
|
||||||
// The cluster's settings, each change checked against this
|
// The cluster's settings, each change checked against this
|
||||||
// machine's own keys before it is kept.
|
// machine's own keys before it is kept.
|
||||||
|
api.SetMCP(st)
|
||||||
|
rt.SetAdminMCP(http.HandlerFunc(api.MCP))
|
||||||
|
api.SetAuth(&adminapi.AuthAdmin{Status: rt.AuthStatus, TestLDAP: rt.TestDirectory, Secrets: st, Log: log})
|
||||||
api.SetSettings(&adminapi.Settings{Store: st, LocalKeys: config.LocalKeys(),
|
api.SetSettings(&adminapi.Settings{Store: st, LocalKeys: config.LocalKeys(),
|
||||||
Check: func(body []byte) error { return checkClusterSettings(configPath, body) }})
|
Check: func(body []byte) error { return checkClusterSettings(configPath, body) }})
|
||||||
// And the same document on the portal, for administrators.
|
// And the same document on the portal, for administrators.
|
||||||
@@ -437,6 +490,14 @@ func newServerCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
api.SetModels(svc)
|
api.SetModels(svc)
|
||||||
catalogSvc = svc
|
catalogSvc = svc
|
||||||
|
// The models that come with llmgw: added by the leader
|
||||||
|
// when a node fits one and the catalog makes nothing of
|
||||||
|
// its kind, unless somebody removed it or turned it off.
|
||||||
|
api.SetPresets(st, func() adminapi.PresetConfig {
|
||||||
|
c := current.Load()
|
||||||
|
return adminapi.PresetConfig{Auto: c.Presets.Auto, ComfyUI: c.Engines[catalog.EngineComfyUI]}
|
||||||
|
})
|
||||||
|
go runPresetsPass(ctx, api, admin.Leadership(), log)
|
||||||
// And the mirror page, so a model that has just been
|
// And the mirror page, so a model that has just been
|
||||||
// fetched can be served without writing YAML by hand.
|
// fetched can be served without writing YAML by hand.
|
||||||
rt.SetCatalogAdmin(svc)
|
rt.SetCatalogAdmin(svc)
|
||||||
@@ -449,6 +510,7 @@ func newServerCmd() *cobra.Command {
|
|||||||
api.SetUsage(st)
|
api.SetUsage(st)
|
||||||
// Capacity quotas, set and read over this API.
|
// Capacity quotas, set and read over this API.
|
||||||
api.SetQuotas(st)
|
api.SetQuotas(st)
|
||||||
|
api.SetTenancy(st)
|
||||||
}
|
}
|
||||||
api.Register(adminMux)
|
api.Register(adminMux)
|
||||||
|
|
||||||
@@ -510,6 +572,16 @@ func newServerCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("the model mirror: %w", err)
|
return fmt.Errorf("the model mirror: %w", err)
|
||||||
}
|
}
|
||||||
rt.SetMirror(mc)
|
rt.SetMirror(mc)
|
||||||
|
if fwPub != nil {
|
||||||
|
fwPub.setMirror(mc)
|
||||||
|
}
|
||||||
|
// Adapters trained on the nodes are kept there too; sending one
|
||||||
|
// takes longer than the mirror's usual answers.
|
||||||
|
if adminAPI != nil {
|
||||||
|
if up, err := mirror.NewClient(cfg.Mirror.API, cfg.Mirror.TLS, 10*time.Minute); err == nil {
|
||||||
|
adminAPI.SetTrainMirror(up)
|
||||||
|
}
|
||||||
|
}
|
||||||
// Browsing the registry is what turns "paste a resolve URL"
|
// Browsing the registry is what turns "paste a resolve URL"
|
||||||
// into "find the model". It needs a way out to the internet
|
// into "find the model". It needs a way out to the internet
|
||||||
// from this machine, which the mirror's own fetching does not.
|
// from this machine, which the mirror's own fetching does not.
|
||||||
@@ -537,6 +609,7 @@ func newServerCmd() *cobra.Command {
|
|||||||
// A judge a policy names and the catalog lacks is
|
// A judge a policy names and the catalog lacks is
|
||||||
// fetched and served, on the leader.
|
// fetched and served, on the leader.
|
||||||
keeper := &judgeKeeper{
|
keeper := &judgeKeeper{
|
||||||
|
grace: judgeGrace,
|
||||||
library: gate.Library,
|
library: gate.Library,
|
||||||
models: func() []catalog.Model { return admin.Catalog().Models() },
|
models: func() []catalog.Model { return admin.Catalog().Models() },
|
||||||
serve: serve,
|
serve: serve,
|
||||||
@@ -545,6 +618,25 @@ func newServerCmd() *cobra.Command {
|
|||||||
},
|
},
|
||||||
gpus: func() []adminapi.ClassUse { return adminAPI.Summary().GPUs },
|
gpus: func() []adminapi.ClassUse { return adminAPI.Summary().GPUs },
|
||||||
log: log,
|
log: log,
|
||||||
|
// An earlier install's judge, moved to today's figures.
|
||||||
|
profiles: catalogSvc,
|
||||||
|
restart: admin.Reconciler().Restart,
|
||||||
|
replicas: func(model string) []string {
|
||||||
|
var ids []string
|
||||||
|
for _, r := range admin.Reconciler().ReadyReplicas() {
|
||||||
|
if r.Model == model {
|
||||||
|
ids = append(ids, r.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
},
|
||||||
|
nodeCPUs: func() []int {
|
||||||
|
var out []int
|
||||||
|
for _, n := range admin.Nodes() {
|
||||||
|
out = append(out, n.CPUCount)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
},
|
||||||
}
|
}
|
||||||
go keeper.run(ctx, 30*time.Second)
|
go keeper.run(ctx, 30*time.Second)
|
||||||
}
|
}
|
||||||
@@ -594,21 +686,6 @@ func newServerCmd() *cobra.Command {
|
|||||||
ReadTimeout: time.Minute,
|
ReadTimeout: time.Minute,
|
||||||
})
|
})
|
||||||
|
|
||||||
apiLn, err := net.Listen("tcp", cfg.Listen)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("listening on %s: %w", cfg.Listen, err)
|
|
||||||
}
|
|
||||||
if cfg.MaxConnections > 0 {
|
|
||||||
// Past this, new connections wait in the kernel's backlog
|
|
||||||
// rather than each taking a goroutine and its buffers.
|
|
||||||
apiLn = netutil.LimitListener(apiLn, cfg.MaxConnections)
|
|
||||||
}
|
|
||||||
adminLn, err := net.Listen("tcp", cfg.AdminListen)
|
|
||||||
if err != nil {
|
|
||||||
_ = apiLn.Close()
|
|
||||||
return fmt.Errorf("listening on %s: %w", cfg.AdminListen, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// A metrics-only listener, when one is configured. Started after
|
// A metrics-only listener, when one is configured. Started after
|
||||||
// AdminHandler, which is where the registry it serves is built.
|
// AdminHandler, which is where the registry it serves is built.
|
||||||
var metricsSrv *http.Server
|
var metricsSrv *http.Server
|
||||||
@@ -727,6 +804,12 @@ func newServerCmd() *cobra.Command {
|
|||||||
if agentSvc != nil {
|
if agentSvc != nil {
|
||||||
agentSvc.BroadcastFirewall()
|
agentSvc.BroadcastFirewall()
|
||||||
}
|
}
|
||||||
|
if fwPub != nil {
|
||||||
|
fwPub.Kick()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "on_demand") }) && admin != nil {
|
||||||
|
admin.Reconciler().SetEvictionTimes(next.OnDemand.EvictAfterIdle, next.OnDemand.MinLifetime)
|
||||||
}
|
}
|
||||||
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "agents") }) && agentSvc != nil {
|
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "agents") }) && agentSvc != nil {
|
||||||
// A moment apart per node, so off this goroutine.
|
// A moment apart per node, so off this goroutine.
|
||||||
@@ -1131,18 +1214,18 @@ func servableCheck(llamaVersion string) func(context.Context, catalog.Model) err
|
|||||||
if llamaVersion == "" {
|
if llamaVersion == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
usesLlama := false
|
// Each source a llama.cpp profile loads: its own, or the model's.
|
||||||
for _, p := range m.Profiles {
|
for _, p := range m.Profiles {
|
||||||
if p.Engine == "llamacpp" {
|
if p.Engine != "llamacpp" {
|
||||||
usesLlama = true
|
continue
|
||||||
|
}
|
||||||
|
url, ok := sourcecheck.HTTPSource(m.SourceOf(p))
|
||||||
|
if !ok || !gguf.IsGGUF(url) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if v := gguf.Check(ctx, url, llamaVersion); !v.Servable {
|
||||||
|
return fmt.Errorf("%s cannot be served: %s", m.Name, v.Reason)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
url, ok := sourcecheck.HTTPSource(m.Source)
|
|
||||||
if !usesLlama || !ok || !gguf.IsGGUF(url) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if v := gguf.Check(ctx, url, llamaVersion); !v.Servable {
|
|
||||||
return fmt.Errorf("%s cannot be served: %s", m.Name, v.Reason)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -1239,3 +1322,32 @@ func embedder(replay func(ctx context.Context, path, model string, body []byte)
|
|||||||
return res.Data[0].Embedding, nil
|
return res.Data[0].Embedding, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// presetsEvery is how often the leader looks for presets to add; the first
|
||||||
|
// look waits as long, for the nodes to have registered their cards.
|
||||||
|
const presetsEvery = time.Minute
|
||||||
|
|
||||||
|
// runPresetsPass has the leader add the presets a node fits and the catalog
|
||||||
|
// lacks, now and then.
|
||||||
|
func runPresetsPass(ctx context.Context, api *adminapi.API, lead *control.Leadership, log *slog.Logger) {
|
||||||
|
t := time.NewTicker(presetsEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
}
|
||||||
|
if lead != nil && !lead.IsLeader() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
added, err := api.PresetsPass(ctx)
|
||||||
|
for _, name := range added {
|
||||||
|
log.Info("added a preset by itself: a node fits it and the catalog made nothing of its kind",
|
||||||
|
"model", name, "off_switch", "presets.auto")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
log.Warn("could not add a preset", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// `llmgw server check` once ran a whole second server: words after server
|
||||||
|
// that name no subcommand are refused.
|
||||||
|
func TestServerTakesNoWordsItDoesNotKnow(t *testing.T) {
|
||||||
|
cmd := newServerCmd()
|
||||||
|
cmd.SetArgs([]string{"check", "--config", "/nonexistent"})
|
||||||
|
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||||
|
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "unknown command") {
|
||||||
|
t.Errorf("err %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second server whose port is taken stops at once, before the router or
|
||||||
|
// any control plane is built.
|
||||||
|
func TestAServerWhosePortIsTakenStopsFirst(t *testing.T) {
|
||||||
|
taken, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer taken.Close()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
cmd := newServerCmd()
|
||||||
|
cmd.SetArgs([]string{"--listen", taken.Addr().String(), "--admin-listen", "127.0.0.1:0",
|
||||||
|
"--endpoint", "a=http://127.0.0.1:1=m"})
|
||||||
|
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||||
|
if err := cmd.ExecuteContext(ctx); err == nil || !strings.Contains(err.Error(), "listening on") {
|
||||||
|
t.Errorf("err %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"encoding/pem"
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"hash/fnv"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
@@ -44,6 +45,11 @@ type serverAdd struct {
|
|||||||
// addr is the machine's address as given; ip what the others reach it at.
|
// addr is the machine's address as given; ip what the others reach it at.
|
||||||
addr, ip, name string
|
addr, ip, name string
|
||||||
out io.Writer
|
out io.Writer
|
||||||
|
// priority is its keepalived priority, under this machine's 110; 0 is
|
||||||
|
// 100. Two standbys get two, so which takes the address is not a tie.
|
||||||
|
priority int
|
||||||
|
// quiet leaves out the closing sentence, for init, which says its own.
|
||||||
|
quiet bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServerAddCmd() *cobra.Command {
|
func newServerAddCmd() *cobra.Command {
|
||||||
@@ -148,7 +154,9 @@ func (sa *serverAdd) run(ctx context.Context) error {
|
|||||||
return fmt.Errorf("%s: %w", s.name, err)
|
return fmt.Errorf("%s: %w", s.name, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fmt.Fprintf(sa.out, "\n%s is a control plane of this cluster, standing by while this one leads.\n", sa.name)
|
if !sa.quiet {
|
||||||
|
fmt.Fprintf(sa.out, "\n%s is a control plane of this cluster, standing by while this one leads.\n", sa.name)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,7 +168,7 @@ func (sa *serverAdd) firewall(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// What applies the cluster's firewall setting there when it changes.
|
// What applies the cluster's firewall setting there when it changes.
|
||||||
if err := installFirewallSync(ctx, sa.remote); err != nil {
|
if err := installFirewallSync(ctx, sa.remote, firewallStateFile); err != nil {
|
||||||
fmt.Fprintf(sa.out, " !! firewall: %v\n", err)
|
fmt.Fprintf(sa.out, " !! firewall: %v\n", err)
|
||||||
}
|
}
|
||||||
if !cfg.Firewall.Enabled {
|
if !cfg.Firewall.Enabled {
|
||||||
@@ -511,18 +519,31 @@ func (sa *serverAdd) floating(ctx context.Context) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
here := pgha.Local{}
|
here := pgha.Local{}
|
||||||
|
priority := sa.priority
|
||||||
|
if priority <= 0 {
|
||||||
|
priority = 100
|
||||||
|
}
|
||||||
for _, m := range []struct {
|
for _, m := range []struct {
|
||||||
r onboard.Remote
|
r onboard.Remote
|
||||||
name string
|
name string
|
||||||
priority int
|
priority int
|
||||||
}{{here, "this machine", 110}, {sa.remote, sa.name, 100}} {
|
}{{here, "this machine", 110}, {sa.remote, sa.name, priority}} {
|
||||||
|
// The interface on the address's network, by its route: not the
|
||||||
|
// route to the address, which is lo on the machine holding it
|
||||||
|
// already, as a run after the first finds it.
|
||||||
out, err := m.r.Run(ctx, fmt.Sprintf(`set -e
|
out, err := m.r.Run(ctx, fmt.Sprintf(`set -e
|
||||||
command -v keepalived >/dev/null 2>&1 || { sudo DEBIAN_FRONTEND=noninteractive apt-get -qq update >/dev/null; sudo DEBIAN_FRONTEND=noninteractive apt-get -qq install -y keepalived >/dev/null; }
|
command -v keepalived >/dev/null 2>&1 || { sudo DEBIAN_FRONTEND=noninteractive apt-get -qq update >/dev/null; sudo DEBIAN_FRONTEND=noninteractive apt-get -qq install -y keepalived >/dev/null; }
|
||||||
ip -o route get %s | awk '{for(i=1;i<=NF;i++) if($i=="dev") {print $(i+1); exit}}'`, vip), nil)
|
dev() { awk '{for(i=1;i<=NF;i++) if($i=="dev") {print $(i+1); exit}}'; }
|
||||||
|
d=$(ip -o -4 route show %s | dev)
|
||||||
|
[ -n "$d" ] && [ "$d" != lo ] || d=$(ip -o route get %s | dev)
|
||||||
|
echo "$d"`, nw.String(), vip), nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("on %s: %v: %s", m.name, err, strings.TrimSpace(string(out)))
|
return fmt.Errorf("on %s: %v: %s", m.name, err, strings.TrimSpace(string(out)))
|
||||||
}
|
}
|
||||||
iface := strings.TrimSpace(string(out))
|
iface := strings.TrimSpace(string(out))
|
||||||
|
if iface == "" || iface == "lo" {
|
||||||
|
return fmt.Errorf("on %s: no interface on %s's network", m.name, sa.vip)
|
||||||
|
}
|
||||||
conf := keepalivedConf(iface, vip.String(), prefix, m.priority, pass)
|
conf := keepalivedConf(iface, vip.String(), prefix, m.priority, pass)
|
||||||
if out, err := m.r.Run(ctx, `set -e
|
if out, err := m.r.Run(ctx, `set -e
|
||||||
sudo install -d -m 0755 /etc/keepalived
|
sudo install -d -m 0755 /etc/keepalived
|
||||||
@@ -560,6 +581,17 @@ func vrrpPassword(etcDir string) (string, error) {
|
|||||||
return pass, nil
|
return pass, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vrrpRouterID is the VRRP router id for a floating address: from the
|
||||||
|
// address, so two clusters on one network, each with its own address, do
|
||||||
|
// not answer each other's adverts — with one id for all, each logged the
|
||||||
|
// other's as an invalid password, and dropped its own address's
|
||||||
|
// announcements with them.
|
||||||
|
func vrrpRouterID(vip string) int {
|
||||||
|
h := fnv.New32a()
|
||||||
|
_, _ = h.Write([]byte(vip))
|
||||||
|
return int(h.Sum32()%255) + 1
|
||||||
|
}
|
||||||
|
|
||||||
func keepalivedConf(iface, vip string, prefix, priority int, pass string) string {
|
func keepalivedConf(iface, vip string, prefix, priority int, pass string) string {
|
||||||
return fmt.Sprintf(`# llmgw: the gateway's floating address, on whichever control plane leads.
|
return fmt.Sprintf(`# llmgw: the gateway's floating address, on whichever control plane leads.
|
||||||
vrrp_script llmgw_leader {
|
vrrp_script llmgw_leader {
|
||||||
@@ -572,7 +604,7 @@ vrrp_script llmgw_leader {
|
|||||||
vrrp_instance llmgw {
|
vrrp_instance llmgw {
|
||||||
state BACKUP
|
state BACKUP
|
||||||
interface %s
|
interface %s
|
||||||
virtual_router_id 51
|
virtual_router_id %d
|
||||||
priority %d
|
priority %d
|
||||||
advert_int 1
|
advert_int 1
|
||||||
authentication {
|
authentication {
|
||||||
@@ -586,7 +618,7 @@ vrrp_instance llmgw {
|
|||||||
llmgw_leader
|
llmgw_leader
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`, iface, priority, pass, vip, prefix)
|
`, iface, vrrpRouterID(vip), priority, pass, vip, prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
// clientCapable says a control plane's certificate can also present itself
|
// clientCapable says a control plane's certificate can also present itself
|
||||||
|
|||||||
+139
-18
@@ -73,10 +73,20 @@ type serverInit struct {
|
|||||||
// patroni are user@host of the machines to keep the store on under
|
// patroni are user@host of the machines to keep the store on under
|
||||||
// Patroni, replicated; this machine is the third etcd vote for two.
|
// Patroni, replicated; this machine is the third etcd vote for two.
|
||||||
patroni []string
|
patroni []string
|
||||||
|
// webhooks are where the cluster's alerts go: put into its settings
|
||||||
|
// once it runs, and each sent a test.
|
||||||
|
webhooks []string
|
||||||
// patroniNodes are the same machines already connected to, which the
|
// patroniNodes are the same machines already connected to, which the
|
||||||
// setup screen does with the password it was given; when set, patroni
|
// setup screen does with the password it was given; when set, patroni
|
||||||
// only names them.
|
// only names them.
|
||||||
patroniNodes []pgha.Node
|
patroniNodes []pgha.Node
|
||||||
|
// judgeMachines are machines for the guardrails' judges alone, added as
|
||||||
|
// nodes labelled for them; judgeCPUs their cores, once added.
|
||||||
|
judgeMachines []judgeMachine
|
||||||
|
judgeCPUs []int
|
||||||
|
// judgeNote is what timing the judge found, said before the next
|
||||||
|
// steps when it is not fine.
|
||||||
|
judgeNote string
|
||||||
// noMirror, noNode and noModel turn off the parts of a first install that
|
// noMirror, noNode and noModel turn off the parts of a first install that
|
||||||
// make it serve something: a mirror, this machine as a node, and a small
|
// make it serve something: a mirror, this machine as a node, and a small
|
||||||
// model on it.
|
// model on it.
|
||||||
@@ -92,9 +102,34 @@ type serverInit struct {
|
|||||||
// bundle is an offline bundle to install from, and bundleSHA its
|
// bundle is an offline bundle to install from, and bundleSHA its
|
||||||
// manifest's sha256: the cluster is then set up offline.
|
// manifest's sha256: the cluster is then set up offline.
|
||||||
bundle, bundleSHA string
|
bundle, bundleSHA string
|
||||||
// mirrorPath is the mirror's directory here; "" is /srv/models. A seam
|
// mirrorPath is the mirror's directory here; "" is /srv/models.
|
||||||
// for tests.
|
|
||||||
mirrorPath string
|
mirrorPath string
|
||||||
|
|
||||||
|
// What setup lets a person choose besides the parts: zero is the
|
||||||
|
// default (serverinit_choices.go).
|
||||||
|
starter starterSpec
|
||||||
|
starterCtx int
|
||||||
|
judge judgeSpec
|
||||||
|
// language is the portal's for a browser that asks for neither of its
|
||||||
|
// own; "" leaves it English.
|
||||||
|
language string
|
||||||
|
signupOpen bool
|
||||||
|
defaultRPM, defaultTPM int
|
||||||
|
maxKeyTTL time.Duration
|
||||||
|
// media has the nodes install ComfyUI and the leader add a picture model
|
||||||
|
// and a 3D model once a card fits one.
|
||||||
|
media bool
|
||||||
|
// evictAfterIdle and minLifetime are on_demand's; zero leaves its own.
|
||||||
|
evictAfterIdle, minLifetime time.Duration
|
||||||
|
// standbys makes each machine of the replicated store a control plane
|
||||||
|
// too, standing by while this one leads: three machines, each able to
|
||||||
|
// take over. vip is a floating address, as 10.0.0.160/24, kept on
|
||||||
|
// whichever leads.
|
||||||
|
standbys bool
|
||||||
|
vip string
|
||||||
|
// cpus is this machine's CPU count for dividing it between init's
|
||||||
|
// models; zero asks the machine. A seam for tests.
|
||||||
|
cpus int
|
||||||
// pgSetup prepares PostgreSQL and returns the store address; a seam for
|
// pgSetup prepares PostgreSQL and returns the store address; a seam for
|
||||||
// tests. Nil does it for real.
|
// tests. Nil does it for real.
|
||||||
pgSetup func(context.Context) (string, error)
|
pgSetup func(context.Context) (string, error)
|
||||||
@@ -119,7 +154,10 @@ func newDefaultServerInit() serverInit {
|
|||||||
|
|
||||||
func newServerInitCmd() *cobra.Command {
|
func newServerInitCmd() *cobra.Command {
|
||||||
in := newDefaultServerInit()
|
in := newDefaultServerInit()
|
||||||
var plain bool
|
var (
|
||||||
|
plain bool
|
||||||
|
starter, judgeCats string
|
||||||
|
)
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "init",
|
Use: "init",
|
||||||
Short: "Set this machine up as a control plane and start it",
|
Short: "Set this machine up as a control plane and start it",
|
||||||
@@ -155,6 +193,9 @@ func newServerInitCmd() *cobra.Command {
|
|||||||
if os.Geteuid() != 0 {
|
if os.Geteuid() != 0 {
|
||||||
return errors.New("init creates a user, writes /etc and starts a service: run it as root")
|
return errors.New("init creates a user, writes /etc and starts a service: run it as root")
|
||||||
}
|
}
|
||||||
|
if err := in.readChoices(starter, judgeCats); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
in.out = cmd.OutOrStdout()
|
in.out = cmd.OutOrStdout()
|
||||||
if !plain && isTerminal(in.out) {
|
if !plain && isTerminal(in.out) {
|
||||||
return in.runInitTUI(cmd.Context(), in.out)
|
return in.runInitTUI(cmd.Context(), in.out)
|
||||||
@@ -182,9 +223,42 @@ func newServerInitCmd() *cobra.Command {
|
|||||||
"leave the firewall alone, instead of keeping llmgw's internal ports to the cluster's networks")
|
"leave the firewall alone, instead of keeping llmgw's internal ports to the cluster's networks")
|
||||||
f.BoolVar(&in.postgres, "postgres", false,
|
f.BoolVar(&in.postgres, "postgres", false,
|
||||||
"keep the store in PostgreSQL on this machine, installing it and moving an existing SQLite store into it")
|
"keep the store in PostgreSQL on this machine, installing it and moving an existing SQLite store into it")
|
||||||
|
f.StringArrayVar(&in.webhooks, "webhook", nil,
|
||||||
|
"where alerts go — a model parked, a node lost, a budget spent: an http(s) address, Slack's, Mattermost's "+
|
||||||
|
"and Rocket.Chat's sent Slack's text, any other the event as JSON (slack: or json: in front says which); repeatable")
|
||||||
f.StringArrayVar(&in.patroni, "patroni", nil,
|
f.StringArrayVar(&in.patroni, "patroni", nil,
|
||||||
"user@host of a machine to keep the store on, replicated under Patroni; give two or more. "+
|
"user@host of a machine to keep the store on, replicated under Patroni; give two or more. "+
|
||||||
"Installs PostgreSQL, Patroni and etcd there over ssh, and moves the existing store in")
|
"Installs PostgreSQL, Patroni and etcd there over ssh, and moves the existing store in")
|
||||||
|
|
||||||
|
// What server.yaml is written with; each is in it only when init writes
|
||||||
|
// it, never into one already there.
|
||||||
|
f.StringVar(&in.mirrorPath, "mirror-dir", mirrorDir, "the directory the mirror on this machine keeps model files in")
|
||||||
|
f.StringVar(&starter, "starter", starterName,
|
||||||
|
"the starter model: "+strings.Join(starterNames(), ", ")+", or hf:<owner>/<repo>/<file>.gguf")
|
||||||
|
f.IntVar(&in.starterCtx, "starter-context", defaultContext, "the context, in tokens, a client may use of the starter model")
|
||||||
|
f.StringVar(&in.judge.Fail, "judge-fail", "closed",
|
||||||
|
"a request no judge read in time: closed refuses it, open serves it flagged")
|
||||||
|
f.StringVar(&judgeCats, "judge-categories", strings.Join(defaultJudgeCategories, ","),
|
||||||
|
"what the judge refuses, comma-separated, of: "+strings.Join(judgeCategories, ", "))
|
||||||
|
f.IntVar(&in.judge.Replicas, "judge-replicas", 2, "how many copies of the judge run, each on CPUs")
|
||||||
|
f.Var(judgeMachineFlag{&in.judgeMachines}, "judge-node",
|
||||||
|
"user@host of a machine for the guardrails' judges alone: added as a node, and the judges run only there; repeatable")
|
||||||
|
f.DurationVar(&in.judge.Budget, "judge-budget", 5*time.Second, "how long a request waits for the judge")
|
||||||
|
f.StringVar(&in.language, "language", "", "the portal's language for a browser that asks for neither: en or tr")
|
||||||
|
f.BoolVar(&in.signupOpen, "signup-open", false, "let anyone ask for an account on the portal, to be approved by an administrator")
|
||||||
|
f.IntVar(&in.defaultRPM, "default-rpm", defaultRPM, "requests a minute a key may make when nothing gives it a limit")
|
||||||
|
f.IntVar(&in.defaultTPM, "default-tpm", defaultTPM, "tokens a minute a key may use when nothing gives it a limit")
|
||||||
|
f.DurationVar(&in.maxKeyTTL, "max-key-ttl", defaultMaxKeyTTL, "the longest an API key lives")
|
||||||
|
f.BoolVar(&in.media, "media", false,
|
||||||
|
"pictures and 3D: the nodes install ComfyUI, and a picture model and a 3D model are added once a card fits one")
|
||||||
|
f.DurationVar(&in.evictAfterIdle, "evict-after-idle", 0,
|
||||||
|
"how long a model loaded on demand goes unasked-for before another may have its card (0: 2m)")
|
||||||
|
f.DurationVar(&in.minLifetime, "min-lifetime", 0,
|
||||||
|
"how long a model just loaded on demand is safe from that (0: 5m)")
|
||||||
|
f.BoolVar(&in.standbys, "standbys", false,
|
||||||
|
"with --patroni, make each of those machines a control plane too, standing by while this one leads")
|
||||||
|
f.StringVar(&in.vip, "vip", "",
|
||||||
|
"with --standbys, a floating address for whichever control plane leads, as 10.0.0.160/24 (keepalived on each)")
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +266,9 @@ func (in *serverInit) run(ctx context.Context) error {
|
|||||||
if err := in.checkBundle(); err != nil {
|
if err := in.checkBundle(); err != nil {
|
||||||
return fmt.Errorf("offline bundle: %w", err)
|
return fmt.Errorf("offline bundle: %w", err)
|
||||||
}
|
}
|
||||||
|
if err := in.checkMemory(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
steps := []struct {
|
steps := []struct {
|
||||||
name string
|
name string
|
||||||
fn func(context.Context) error
|
fn func(context.Context) error
|
||||||
@@ -249,6 +326,14 @@ func (in *serverInit) run(ctx context.Context) error {
|
|||||||
fmt.Fprintf(in.out, " !! firewall: %v; llmgw's ports answer every network this machine is on\n", err)
|
fmt.Fprintf(in.out, " !! firewall: %v; llmgw's ports answer every network this machine is on\n", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := in.ensureWebhooks(ctx); err != nil {
|
||||||
|
fmt.Fprintf(in.out, " !! webhooks: %v\n", err)
|
||||||
|
}
|
||||||
|
if hasSystemd && len(in.judgeMachines) > 0 {
|
||||||
|
if err := in.ensureJudgeNodes(ctx); err != nil {
|
||||||
|
return fmt.Errorf("judge nodes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
if in.judgeWanted() && in.configJudgesWith(guardModelName) {
|
if in.judgeWanted() && in.configJudgesWith(guardModelName) {
|
||||||
// Not fatal: without its judge the baseline policy still checks for
|
// Not fatal: without its judge the baseline policy still checks for
|
||||||
// secrets and injection, and a judged request is served flagged.
|
// secrets and injection, and a judged request is served flagged.
|
||||||
@@ -265,6 +350,14 @@ func (in *serverInit) run(ctx context.Context) error {
|
|||||||
return fmt.Errorf("starter model: %w", err)
|
return fmt.Errorf("starter model: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if in.standbys {
|
||||||
|
if err := in.ensureStandbys(ctx); err != nil {
|
||||||
|
return fmt.Errorf("standby control planes: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hasSystemd && in.judgeWanted() && in.configJudgesWith(guardModelName) {
|
||||||
|
in.checkJudge(ctx)
|
||||||
|
}
|
||||||
in.nextSteps()
|
in.nextSteps()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -301,29 +394,45 @@ func (in *serverInit) configJudgesWith(model string) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// guardSection is server.yaml's guard: the baseline every request passes,
|
// guardSection is server.yaml's guard: the baseline every request passes,
|
||||||
// with the judge model when there is one.
|
// with the judge model, as init sets it up unless told otherwise, when there
|
||||||
|
// is one.
|
||||||
func guardSection(judge bool) string {
|
func guardSection(judge bool) string {
|
||||||
j := ` # A judge model reads what patterns cannot: violence, weapons, abuse,
|
if !judge {
|
||||||
|
return guardSectionFor(nil)
|
||||||
|
}
|
||||||
|
j := (&serverInit{}).judgeFor()
|
||||||
|
return guardSectionFor(&j)
|
||||||
|
}
|
||||||
|
|
||||||
|
// guardSectionFor is server.yaml's guard with this judge, or none.
|
||||||
|
func guardSectionFor(j *judgeSpec) string {
|
||||||
|
fail := "closed"
|
||||||
|
jb := ` # A judge model reads what patterns cannot: violence, weapons, abuse,
|
||||||
# self-harm. Installed by init; --no-guard-model leaves it out.
|
# self-harm. Installed by init; --no-guard-model leaves it out.
|
||||||
# judge: {model: ` + guardModelName + `, input: block, output: block, ...}
|
# judge: {model: ` + guardModelName + `, input: block, output: block, ...}
|
||||||
`
|
`
|
||||||
if judge {
|
if j != nil {
|
||||||
j = ` # A judge model reads what patterns cannot: violence, crime, sexual
|
fail = j.Fail
|
||||||
# content, self-harm, jailbreaks. Two replicas run on CPUs, about two
|
copies := map[int]string{1: "One replica runs", 2: "Two replicas run", 3: "Three replicas run"}[j.Replicas]
|
||||||
|
if copies == "" {
|
||||||
|
copies = fmt.Sprintf("%d replicas run", j.Replicas)
|
||||||
|
}
|
||||||
|
jb = ` # A judge model reads what patterns cannot: violence, crime, sexual
|
||||||
|
# content, self-harm, jailbreaks. ` + copies + ` on CPUs, about two
|
||||||
# seconds for a short text; an answer is read every 600 characters as
|
# 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
|
# it streams. With fail: closed, a request no judge can read within
|
||||||
# latency_budget is refused (503); open serves it, flagged, unjudged.
|
# latency_budget is refused (503); open serves it, flagged, unjudged.
|
||||||
# PII is the patterns' (above), and copyright, unethical acts and
|
# PII is the patterns' (above), and copyright, unethical acts and
|
||||||
# politically sensitive topics are left out: they refuse too much
|
# politically sensitive topics are left out unless chosen: they refuse
|
||||||
# ordinary work. Controversial counts as safe; controversial: unsafe
|
# too much ordinary work. Controversial counts as safe; controversial:
|
||||||
# makes it count. With a GPU: llmgw ctl guard judge enable --gpu, then
|
# unsafe makes it count. With a GPU: llmgw ctl guard judge enable --gpu,
|
||||||
# llmgw ctl guard judge use ` + defaultGPUJudge + ` ` + guardModelName + `.
|
# then llmgw ctl guard judge use ` + defaultGPUJudge + ` ` + guardModelName + `.
|
||||||
judge:
|
judge:
|
||||||
models: [` + guardModelName + `]
|
models: [` + guardModelName + `]
|
||||||
input: block
|
input: block
|
||||||
output: block
|
output: block
|
||||||
categories: [Violent, Non-violent Illegal Acts, Sexual Content or Sexual Acts, Suicide & Self-Harm, Jailbreak]
|
categories: [` + strings.Join(j.Categories, ", ") + `]
|
||||||
latency_budget: 5s
|
latency_budget: ` + durationYAML(j.Budget) + `
|
||||||
`
|
`
|
||||||
}
|
}
|
||||||
return `# Guardrails (docs/guardrails.md). The baseline applies to every request;
|
return `# Guardrails (docs/guardrails.md). The baseline applies to every request;
|
||||||
@@ -333,13 +442,13 @@ guard:
|
|||||||
default: [baseline]
|
default: [baseline]
|
||||||
policies:
|
policies:
|
||||||
baseline:
|
baseline:
|
||||||
fail: closed
|
fail: ` + fail + `
|
||||||
input:
|
input:
|
||||||
pii: {secret: block, card: redact}
|
pii: {secret: block, card: redact}
|
||||||
injection: flag
|
injection: flag
|
||||||
output:
|
output:
|
||||||
pii: {secret: block, card: redact}
|
pii: {secret: block, card: redact}
|
||||||
` + j
|
` + jb
|
||||||
}
|
}
|
||||||
|
|
||||||
func (in *serverInit) done(format string, a ...any) {
|
func (in *serverInit) done(format string, a ...any) {
|
||||||
@@ -518,6 +627,10 @@ func (in *serverInit) certHosts() []string {
|
|||||||
for _, h := range in.hosts {
|
for _, h := range in.hosts {
|
||||||
add(h)
|
add(h)
|
||||||
}
|
}
|
||||||
|
// The floating address is this machine's whenever it leads.
|
||||||
|
if ip, _, err := net.ParseCIDR(in.vip); err == nil {
|
||||||
|
add(ip.String())
|
||||||
|
}
|
||||||
if name, err := os.Hostname(); err == nil {
|
if name, err := os.Hostname(); err == nil {
|
||||||
add(name)
|
add(name)
|
||||||
}
|
}
|
||||||
@@ -556,8 +669,13 @@ func (in *serverInit) ensureConfig(context.Context) error {
|
|||||||
if contact == "" {
|
if contact == "" {
|
||||||
contact = "platform@example.com"
|
contact = "platform@example.com"
|
||||||
}
|
}
|
||||||
body := fmt.Sprintf(serverConfigTemplate, in.etcDir, in.etcDir, in.etcDir,
|
var judge *judgeSpec
|
||||||
in.storeAddress(), contact, guardSection(in.judgeWanted()))
|
if in.judgeWanted() {
|
||||||
|
j := in.judgeFor()
|
||||||
|
judge = &j
|
||||||
|
}
|
||||||
|
body := in.tuneConfig(fmt.Sprintf(serverConfigTemplate, in.etcDir, in.etcDir, in.etcDir,
|
||||||
|
in.storeAddress(), contact, guardSectionFor(judge)))
|
||||||
if err := os.WriteFile(path, []byte(body), 0o640); err != nil {
|
if err := os.WriteFile(path, []byte(body), 0o640); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -804,6 +922,9 @@ func (in *serverInit) waitHealthy(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (in *serverInit) nextSteps() {
|
func (in *serverInit) nextSteps() {
|
||||||
|
if in.judgeNote != "" {
|
||||||
|
fmt.Fprint(in.out, "\n"+in.judgeNote)
|
||||||
|
}
|
||||||
fmt.Fprint(in.out, `
|
fmt.Fprint(in.out, `
|
||||||
The control plane is up. Next:
|
The control plane is up. Next:
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
|
||||||
|
)
|
||||||
|
|
||||||
|
// What a first install lets a person choose beyond which parts it has: which
|
||||||
|
// starter model, how the judge decides, what the portal speaks, the limits a
|
||||||
|
// key starts with, and whether pictures and 3D come by themselves. Each zero
|
||||||
|
// value is what init did before it could be chosen, so `llmgw server init`
|
||||||
|
// with no flags installs what it always did.
|
||||||
|
//
|
||||||
|
// All of it is written into server.yaml when init writes it, and the cluster's
|
||||||
|
// keys among it become the cluster's first settings when the control plane
|
||||||
|
// first starts. A server.yaml already there is never overwritten, so a second
|
||||||
|
// run changes none of it: the settings page and `llmgw ctl config` do.
|
||||||
|
|
||||||
|
// starterSpec is a model init can fetch and serve so there is something to ask.
|
||||||
|
type starterSpec struct {
|
||||||
|
Name, Alias, Source string
|
||||||
|
// SizeGB is the weights' size, for the memory a node needs; 0 is not known.
|
||||||
|
SizeGB float64
|
||||||
|
About string
|
||||||
|
}
|
||||||
|
|
||||||
|
// starterChoices are the starters offered, smallest first: the first is the
|
||||||
|
// default. Each is a single GGUF file, Q4_K_M.
|
||||||
|
var starterChoices = []starterSpec{
|
||||||
|
{starterName, starterAlias, starterSource, 0.5, "answers at once on any CPU; fast, not clever"},
|
||||||
|
{"qwen2.5-1.5b-instruct", "qwen-1.5b", "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF/qwen2.5-1.5b-instruct-q4_k_m.gguf", 1.1,
|
||||||
|
"still quick on a CPU, and noticeably better"},
|
||||||
|
{"qwen2.5-3b-instruct", "qwen-3b", "hf:Qwen/Qwen2.5-3B-Instruct-GGUF/qwen2.5-3b-instruct-q4_k_m.gguf", 2.1,
|
||||||
|
"slow on a CPU, quick on any GPU"},
|
||||||
|
{"qwen3-4b", "", "hf:Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf", 2.5,
|
||||||
|
"thinks before it answers; a GPU is worth having"},
|
||||||
|
{"qwen3-8b", "", "hf:Qwen/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.gguf", 5.0,
|
||||||
|
"a real assistant; a GPU with 8 GB or more"},
|
||||||
|
{"qwen3-14b", "", "hf:Qwen/Qwen3-14B-GGUF/Qwen3-14B-Q4_K_M.gguf", 9.0,
|
||||||
|
"better still; a GPU with 16 GB or more"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// starterFrom reads --starter: a name from starterChoices, or the hf: source
|
||||||
|
// of any GGUF file, named after the file.
|
||||||
|
func starterFrom(s string) (starterSpec, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
for _, c := range starterChoices {
|
||||||
|
if s == c.Name || (c.Alias != "" && s == c.Alias) {
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutPrefix(s, "hf:"); ok && strings.Count(rest, "/") >= 2 && strings.HasSuffix(strings.ToLower(rest), ".gguf") {
|
||||||
|
name := strings.ToLower(strings.TrimSuffix(path.Base(rest), path.Ext(rest)))
|
||||||
|
return starterSpec{Name: name, Source: s, About: "chosen at install"}, nil
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
for _, c := range starterChoices {
|
||||||
|
names = append(names, c.Name)
|
||||||
|
}
|
||||||
|
return starterSpec{}, fmt.Errorf("starter %q: one of %s, or hf:<owner>/<repo>/<file>.gguf", s, strings.Join(names, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
func starterNames() []string {
|
||||||
|
var out []string
|
||||||
|
for _, c := range starterChoices {
|
||||||
|
out = append(out, c.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// readChoices takes the flags that are not read straight into init: the
|
||||||
|
// starter by its name, the judge's categories by commas. Then it checks them
|
||||||
|
// all.
|
||||||
|
func (in *serverInit) readChoices(starter, categories string) error {
|
||||||
|
if strings.TrimSpace(starter) != "" {
|
||||||
|
s, err := starterFrom(starter)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.starter = s
|
||||||
|
}
|
||||||
|
in.judge.Categories = nil
|
||||||
|
for _, c := range strings.Split(categories, ",") {
|
||||||
|
if c = strings.TrimSpace(c); c != "" {
|
||||||
|
in.judge.Categories = append(in.judge.Categories, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return in.checkChoices()
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkChoices says what among the choices cannot be installed.
|
||||||
|
func (in *serverInit) checkChoices() error {
|
||||||
|
switch in.language {
|
||||||
|
case "", "en", "tr":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("language %q: en or tr", in.language)
|
||||||
|
}
|
||||||
|
switch in.judge.Fail {
|
||||||
|
case "", "closed", "open":
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("judge fail %q: closed or open", in.judge.Fail)
|
||||||
|
}
|
||||||
|
for _, c := range in.judge.Categories {
|
||||||
|
if !slices.Contains(judgeCategories, c) {
|
||||||
|
return fmt.Errorf("judge category %q: one of %s", c, strings.Join(judgeCategories, ", "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case in.judge.Replicas < 0 || in.judge.Replicas > 8:
|
||||||
|
return fmt.Errorf("judge replicas %d: from 1 to 8", in.judge.Replicas)
|
||||||
|
case in.judge.Budget < 0 || in.judge.Budget > time.Minute:
|
||||||
|
return fmt.Errorf("judge budget %s: at most a minute", in.judge.Budget)
|
||||||
|
case in.starterCtx < 0 || in.starterCtx > 262144:
|
||||||
|
return fmt.Errorf("starter context %d: at most 262144 tokens", in.starterCtx)
|
||||||
|
case in.starterCtx > 0 && in.starterCtx < 512:
|
||||||
|
return fmt.Errorf("starter context %d: at least 512 tokens", in.starterCtx)
|
||||||
|
case in.defaultRPM < 0 || in.defaultTPM < 0:
|
||||||
|
return fmt.Errorf("default limits cannot be negative")
|
||||||
|
case in.maxKeyTTL < 0 || (in.maxKeyTTL > 0 && in.maxKeyTTL < time.Hour):
|
||||||
|
return fmt.Errorf("max key ttl %s: an hour at least", in.maxKeyTTL)
|
||||||
|
case in.evictAfterIdle < 0 || in.minLifetime < 0:
|
||||||
|
return fmt.Errorf("on-demand times cannot be negative")
|
||||||
|
case in.vip != "" && !in.standbys:
|
||||||
|
return fmt.Errorf("a floating address moves between control planes: it needs the standbys")
|
||||||
|
}
|
||||||
|
if in.vip != "" {
|
||||||
|
ip, _, err := net.ParseCIDR(in.vip)
|
||||||
|
if err != nil || ip.To4() == nil {
|
||||||
|
return fmt.Errorf("floating address %q: an IPv4 address with its prefix, as 10.0.0.160/24", in.vip)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// starterModelSpec is the starter this install serves.
|
||||||
|
func (in *serverInit) starterModelSpec() starterSpec {
|
||||||
|
if in.starter.Source == "" {
|
||||||
|
return starterChoices[0]
|
||||||
|
}
|
||||||
|
return in.starter
|
||||||
|
}
|
||||||
|
|
||||||
|
// minMemoryGB is what a node needs free for the starter: its weights and
|
||||||
|
// room for its context, never less than the 2 GB it always asked.
|
||||||
|
func (s starterSpec) minMemoryGB() int {
|
||||||
|
return max(2, int(math.Ceil(s.SizeGB+1.5)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// servingMemoryMB is what this machine needs, beyond what the control plane
|
||||||
|
// does, for the models it will serve itself: the starter's and the judges'
|
||||||
|
// reservations, and a gigabyte more because the scheduler counts a machine's
|
||||||
|
// memory in whole gigabytes, rounded down — 7.75 GB is 7. Zero when it serves
|
||||||
|
// none of them; what names those it does.
|
||||||
|
//
|
||||||
|
// Asked before anything is installed: the 8 GB machine the guide was first
|
||||||
|
// recorded on finished every step and then had no room for the starter, the
|
||||||
|
// one thing a person asks first.
|
||||||
|
func (in *serverInit) servingMemoryMB(judgesElsewhere bool) (mb int, what string) {
|
||||||
|
if in.noNode {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
gb := 0
|
||||||
|
var parts []string
|
||||||
|
if !in.noModel && (!in.noMirror || in.mirrorURL != "") {
|
||||||
|
gb += in.starterModelSpec().minMemoryGB()
|
||||||
|
parts = append(parts, "the starter model")
|
||||||
|
}
|
||||||
|
if !in.noGuardModel && !judgesElsewhere {
|
||||||
|
n := in.judgeFor().Replicas
|
||||||
|
gb += n * judgeMemoryGB
|
||||||
|
parts = append(parts, fmt.Sprintf("%d judges", n))
|
||||||
|
}
|
||||||
|
if gb == 0 {
|
||||||
|
return 0, ""
|
||||||
|
}
|
||||||
|
return (gb + 1) * 1024, strings.Join(parts, " and ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkMemory refuses, before anything is installed, a machine too small for
|
||||||
|
// the models it would serve as well as the control plane.
|
||||||
|
func (in *serverInit) checkMemory() error {
|
||||||
|
if in.unitPath == "" {
|
||||||
|
return nil // a development run, served by nothing
|
||||||
|
}
|
||||||
|
meminfo, err := os.ReadFile("/proc/meminfo")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
total, ok := pgha.MemTotal(string(meminfo))
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
extra, what := in.servingMemoryMB(len(in.judgeMachines) > 0)
|
||||||
|
if extra == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
have, need := int(total>>20), preflight.Needs[preflight.RoleControl].MemoryMB+extra
|
||||||
|
if have >= need {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("memory: this machine has %d MB; the control plane and %s it would serve need %d. "+
|
||||||
|
"Give it more, or leave them off it: --no-model, --no-guard-model, or --no-node to serve models only on nodes added later", have, what, need)
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultContext is the context a client may use of the starter.
|
||||||
|
const defaultContext = 4096
|
||||||
|
|
||||||
|
// starterContext is the context a client may use of the starter.
|
||||||
|
func (in *serverInit) starterContext() int {
|
||||||
|
if in.starterCtx > 0 {
|
||||||
|
return in.starterCtx
|
||||||
|
}
|
||||||
|
return defaultContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeSpec is how the guardrails' judge decides.
|
||||||
|
type judgeSpec struct {
|
||||||
|
// Fail is what is done with a request no judge read in time: closed
|
||||||
|
// refuses it, open serves it flagged.
|
||||||
|
Fail string
|
||||||
|
Categories []string
|
||||||
|
Replicas int
|
||||||
|
Budget time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeCategories are the ones a judge can be asked about, as Qwen3Guard
|
||||||
|
// names them; defaultJudgeCategories those it is asked about unless told.
|
||||||
|
var (
|
||||||
|
judgeCategories = []string{"Violent", "Non-violent Illegal Acts", "Sexual Content or Sexual Acts",
|
||||||
|
"Suicide & Self-Harm", "Jailbreak", "PII", "Unethical Acts", "Politically Sensitive Topics", "Copyright Violation"}
|
||||||
|
defaultJudgeCategories = judgeCategories[:5]
|
||||||
|
)
|
||||||
|
|
||||||
|
// judgeFor is the judge as this install sets it up, the defaults filled in.
|
||||||
|
func (in *serverInit) judgeFor() judgeSpec {
|
||||||
|
j := in.judge
|
||||||
|
if j.Fail == "" {
|
||||||
|
j.Fail = "closed"
|
||||||
|
}
|
||||||
|
if len(j.Categories) == 0 {
|
||||||
|
j.Categories = defaultJudgeCategories
|
||||||
|
}
|
||||||
|
if j.Replicas <= 0 {
|
||||||
|
j.Replicas = 2
|
||||||
|
if in.singleMachine() {
|
||||||
|
j.Replicas = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if j.Budget <= 0 {
|
||||||
|
j.Budget = 5 * time.Second
|
||||||
|
}
|
||||||
|
return j
|
||||||
|
}
|
||||||
|
|
||||||
|
// singleMachine is an install where this machine is the only node there
|
||||||
|
// will be, as far as init knows: a second judge replica there shares the
|
||||||
|
// same cores and is lost with the same machine, so it buys nothing but
|
||||||
|
// half the speed of each.
|
||||||
|
func (in *serverInit) singleMachine() bool {
|
||||||
|
return !in.noNode && !in.standbys && len(in.patroni) == 0 && len(in.patroniNodes) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// The configuration's limits, as init writes them unless told otherwise.
|
||||||
|
const (
|
||||||
|
defaultRPM = 60
|
||||||
|
defaultTPM = 200000
|
||||||
|
defaultMaxKeyTTL = 2160 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
// tuneConfig writes this install's choices into the server.yaml template:
|
||||||
|
// the lines the template has, changed, and the blocks it has not, added.
|
||||||
|
// Unchosen, the template comes back as it was.
|
||||||
|
func (in *serverInit) tuneConfig(body string) string {
|
||||||
|
set := func(old, now string) {
|
||||||
|
if old != now {
|
||||||
|
body = strings.Replace(body, old, now, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.signupOpen {
|
||||||
|
set(" signup_open: false", " signup_open: true")
|
||||||
|
}
|
||||||
|
if in.defaultRPM > 0 {
|
||||||
|
set(" default_rpm: 60", " default_rpm: "+strconv.Itoa(in.defaultRPM))
|
||||||
|
}
|
||||||
|
if in.defaultTPM > 0 {
|
||||||
|
set(" default_tpm: 200000", " default_tpm: "+strconv.Itoa(in.defaultTPM))
|
||||||
|
}
|
||||||
|
if in.maxKeyTTL > 0 && in.maxKeyTTL != defaultMaxKeyTTL {
|
||||||
|
set(" max_key_ttl: 2160h # 90 days", fmt.Sprintf(" max_key_ttl: %s # %s", durationYAML(in.maxKeyTTL), days(in.maxKeyTTL)))
|
||||||
|
}
|
||||||
|
if in.language != "" {
|
||||||
|
set(" # language: tr", " language: "+in.language)
|
||||||
|
}
|
||||||
|
var extra []string
|
||||||
|
if in.media {
|
||||||
|
extra = append(extra, "# Pictures and 3D, chosen at install: the nodes install ComfyUI, and the\n"+
|
||||||
|
"# leader adds a picture model and a 3D model by itself once a card fits\n"+
|
||||||
|
"# one ('llmgw ctl model presets auto off' stops it).\n"+
|
||||||
|
"engines:\n comfyui: "+provision.ComfyUIVersion()+"\npresets:\n auto: true\n")
|
||||||
|
}
|
||||||
|
if in.evictAfterIdle > 0 || in.minLifetime > 0 {
|
||||||
|
b := "# How models that load when asked for take turns on a card, chosen at\n# install.\non_demand:\n"
|
||||||
|
if in.evictAfterIdle > 0 {
|
||||||
|
b += " evict_after_idle: " + durationYAML(in.evictAfterIdle) + "\n"
|
||||||
|
}
|
||||||
|
if in.minLifetime > 0 {
|
||||||
|
b += " min_lifetime: " + durationYAML(in.minLifetime) + "\n"
|
||||||
|
}
|
||||||
|
extra = append(extra, b)
|
||||||
|
}
|
||||||
|
if len(extra) > 0 {
|
||||||
|
body += "\n" + strings.Join(extra, "\n")
|
||||||
|
}
|
||||||
|
return body
|
||||||
|
}
|
||||||
|
|
||||||
|
// durationYAML is a duration as a person writes it: 2h, 90m, 1m30s.
|
||||||
|
func durationYAML(d time.Duration) string {
|
||||||
|
switch {
|
||||||
|
case d == 0:
|
||||||
|
return "0s"
|
||||||
|
case d%time.Hour == 0:
|
||||||
|
return fmt.Sprintf("%dh", d/time.Hour)
|
||||||
|
case d%time.Minute == 0:
|
||||||
|
return fmt.Sprintf("%dm", d/time.Minute)
|
||||||
|
}
|
||||||
|
return d.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func days(d time.Duration) string {
|
||||||
|
n := int(d.Hours() / 24)
|
||||||
|
if n == 1 {
|
||||||
|
return "1 day"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d days", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// initFlags is this install as `llmgw server init`'s flags, for doing it
|
||||||
|
// again from a script; what is left at its default is left out.
|
||||||
|
func (in *serverInit) initFlags() []string {
|
||||||
|
var out []string
|
||||||
|
add := func(flag string, v ...string) {
|
||||||
|
if len(v) == 0 {
|
||||||
|
out = append(out, flag)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out = append(out, flag+" "+shellWord(v[0]))
|
||||||
|
}
|
||||||
|
if in.admin != "" {
|
||||||
|
add("--admin", in.admin)
|
||||||
|
}
|
||||||
|
for _, h := range in.hosts {
|
||||||
|
add("--host", h)
|
||||||
|
}
|
||||||
|
if in.language != "" {
|
||||||
|
add("--language", in.language)
|
||||||
|
}
|
||||||
|
if in.signupOpen {
|
||||||
|
add("--signup-open")
|
||||||
|
}
|
||||||
|
if in.defaultRPM > 0 && in.defaultRPM != defaultRPM {
|
||||||
|
add("--default-rpm", strconv.Itoa(in.defaultRPM))
|
||||||
|
}
|
||||||
|
if in.defaultTPM > 0 && in.defaultTPM != defaultTPM {
|
||||||
|
add("--default-tpm", strconv.Itoa(in.defaultTPM))
|
||||||
|
}
|
||||||
|
if in.maxKeyTTL > 0 && in.maxKeyTTL != defaultMaxKeyTTL {
|
||||||
|
add("--max-key-ttl", durationYAML(in.maxKeyTTL))
|
||||||
|
}
|
||||||
|
if in.postgres {
|
||||||
|
add("--postgres")
|
||||||
|
}
|
||||||
|
for _, t := range in.patroni {
|
||||||
|
add("--patroni", t)
|
||||||
|
}
|
||||||
|
for _, m := range in.judgeMachines {
|
||||||
|
add("--judge-node", m.target)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case in.mirrorURL != "":
|
||||||
|
add("--mirror", in.mirrorURL)
|
||||||
|
case in.noMirror:
|
||||||
|
add("--no-mirror")
|
||||||
|
case in.mirrorPath != "" && in.mirrorPath != mirrorDir:
|
||||||
|
add("--mirror-dir", in.mirrorPath)
|
||||||
|
}
|
||||||
|
if in.bundle != "" {
|
||||||
|
add("--bundle", in.bundle)
|
||||||
|
add("--bundle-sha256", in.bundleSHA)
|
||||||
|
}
|
||||||
|
if in.noNode {
|
||||||
|
add("--no-node")
|
||||||
|
}
|
||||||
|
if in.noModel {
|
||||||
|
add("--no-model")
|
||||||
|
} else {
|
||||||
|
if s := in.starterModelSpec(); s.Name != starterName {
|
||||||
|
v := s.Name
|
||||||
|
if !slices.ContainsFunc(starterChoices, func(c starterSpec) bool { return c.Name == s.Name }) {
|
||||||
|
v = s.Source
|
||||||
|
}
|
||||||
|
add("--starter", v)
|
||||||
|
}
|
||||||
|
if in.starterCtx > 0 && in.starterCtx != defaultContext {
|
||||||
|
add("--starter-context", strconv.Itoa(in.starterCtx))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.noGuardModel {
|
||||||
|
add("--no-guard-model")
|
||||||
|
} else {
|
||||||
|
// Against the defaults for an install of the same shape: one judge
|
||||||
|
// on one machine, two otherwise.
|
||||||
|
shape := serverInit{noNode: in.noNode, standbys: in.standbys, patroni: in.patroni, patroniNodes: in.patroniNodes}
|
||||||
|
j, d := in.judgeFor(), shape.judgeFor()
|
||||||
|
if j.Fail != d.Fail {
|
||||||
|
add("--judge-fail", j.Fail)
|
||||||
|
}
|
||||||
|
if !slices.Equal(j.Categories, d.Categories) {
|
||||||
|
add("--judge-categories", strings.Join(j.Categories, ","))
|
||||||
|
}
|
||||||
|
if j.Replicas != d.Replicas {
|
||||||
|
add("--judge-replicas", strconv.Itoa(j.Replicas))
|
||||||
|
}
|
||||||
|
if j.Budget != d.Budget {
|
||||||
|
add("--judge-budget", durationYAML(j.Budget))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.noFirewall {
|
||||||
|
add("--no-firewall")
|
||||||
|
}
|
||||||
|
if in.standbys {
|
||||||
|
add("--standbys")
|
||||||
|
}
|
||||||
|
if in.vip != "" {
|
||||||
|
add("--vip", in.vip)
|
||||||
|
}
|
||||||
|
for _, w := range in.webhooks {
|
||||||
|
for _, s := range strings.Split(w, ",") {
|
||||||
|
if s = strings.TrimSpace(s); s != "" {
|
||||||
|
add("--webhook", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if in.media {
|
||||||
|
add("--media")
|
||||||
|
}
|
||||||
|
if in.evictAfterIdle > 0 {
|
||||||
|
add("--evict-after-idle", durationYAML(in.evictAfterIdle))
|
||||||
|
}
|
||||||
|
if in.minLifetime > 0 {
|
||||||
|
add("--min-lifetime", durationYAML(in.minLifetime))
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellWord quotes a word for a shell when it needs it.
|
||||||
|
func shellWord(s string) string {
|
||||||
|
if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$`&;|<>(){}*?[]#~!") {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEveryChoiceIsInTheConfigurationWrittenAndItLoads(t *testing.T) {
|
||||||
|
in := &serverInit{
|
||||||
|
language: "tr", signupOpen: true, defaultRPM: 120, defaultTPM: 50000, maxKeyTTL: 30 * 24 * time.Hour,
|
||||||
|
media: true, evictAfterIdle: time.Minute, minLifetime: 90 * time.Second,
|
||||||
|
judge: judgeSpec{Fail: "open", Categories: []string{"Violent", "PII"}, Replicas: 3, Budget: 3 * time.Second},
|
||||||
|
}
|
||||||
|
j := in.judgeFor()
|
||||||
|
body := in.tuneConfig(fmt.Sprintf(serverConfigTemplate, "/etc/llmgw", "/etc/llmgw", "/etc/llmgw",
|
||||||
|
"/var/lib/llmgw/llmgw.db", "ops@example.com", guardSectionFor(&j)))
|
||||||
|
path := filepath.Join(t.TempDir(), "server.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v\n%s", err, body)
|
||||||
|
}
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case cfg.Landing.Language != "tr" || !cfg.Auth.SignupOpen:
|
||||||
|
t.Errorf("language %q signup %v", cfg.Landing.Language, cfg.Auth.SignupOpen)
|
||||||
|
case cfg.Auth.DefaultRPM != 120 || cfg.Auth.DefaultTPM != 50000 || cfg.Auth.MaxKeyTTL != 30*24*time.Hour:
|
||||||
|
t.Errorf("limits %d %d %s", cfg.Auth.DefaultRPM, cfg.Auth.DefaultTPM, cfg.Auth.MaxKeyTTL)
|
||||||
|
case !cfg.Presets.Auto || cfg.Engines["comfyui"] == "":
|
||||||
|
t.Errorf("pictures and 3D: presets %v comfyui %q", cfg.Presets.Auto, cfg.Engines["comfyui"])
|
||||||
|
case cfg.OnDemand.EvictAfterIdle != time.Minute || cfg.OnDemand.MinLifetime != 90*time.Second:
|
||||||
|
t.Errorf("on demand %+v", cfg.OnDemand)
|
||||||
|
}
|
||||||
|
base := cfg.Guard.Policies["baseline"]
|
||||||
|
if base.Fail != "open" || base.Judge == nil || !slices.Equal(base.Judge.Categories, []string{"Violent", "PII"}) ||
|
||||||
|
base.Judge.LatencyBudget != 3*time.Second {
|
||||||
|
t.Errorf("the judge as written: fail %q %+v", base.Fail, base.Judge)
|
||||||
|
}
|
||||||
|
if !strings.Contains(body, "Three replicas run on CPUs") {
|
||||||
|
t.Error("the comment says another number of judges than run")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNothingChosenIsTheTemplateAsItWas(t *testing.T) {
|
||||||
|
body := fmt.Sprintf(serverConfigTemplate, "/e", "/e", "/e", "/s.db", "a@b", guardSection(true))
|
||||||
|
if got := (&serverInit{}).tuneConfig(body); got != body {
|
||||||
|
t.Error("an install with nothing chosen writes another configuration than before")
|
||||||
|
}
|
||||||
|
// Each line tuneConfig changes is in the template, or a choice would be
|
||||||
|
// lost without a word.
|
||||||
|
for _, line := range []string{" signup_open: false", " default_rpm: 60", " default_tpm: 200000",
|
||||||
|
" max_key_ttl: 2160h # 90 days", " # language: tr"} {
|
||||||
|
if !strings.Contains(serverConfigTemplate, line) {
|
||||||
|
t.Errorf("the template has no %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAStarterIsChosenByNameOrByItsFile(t *testing.T) {
|
||||||
|
s, err := starterFrom("qwen3-8b")
|
||||||
|
if err != nil || s.SizeGB != 5.0 || !strings.HasPrefix(s.Source, "hf:Qwen/Qwen3-8B-GGUF/") {
|
||||||
|
t.Errorf("by name: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
s, err = starterFrom("hf:bartowski/Phi-4-GGUF/Phi-4-Q4_K_M.gguf")
|
||||||
|
if err != nil || s.Name != "phi-4-q4_k_m" {
|
||||||
|
t.Errorf("by file: %+v %v", s, err)
|
||||||
|
}
|
||||||
|
if _, err := starterFrom("gpt-5"); err == nil {
|
||||||
|
t.Error("a model nobody offers was taken")
|
||||||
|
}
|
||||||
|
m := starterModel(s, 8192, 0, "http://x/p.gguf", []string{"6000-24g"}, true)
|
||||||
|
if m.Name != "phi-4-q4_k_m" || m.ContextLength != 8192 || len(m.Aliases) != 0 ||
|
||||||
|
!slices.Equal(m.Profiles[0].Args, []string{"--ctx-size", "32768"}) {
|
||||||
|
t.Errorf("entry %+v", m)
|
||||||
|
}
|
||||||
|
big, _ := starterFrom("qwen3-14b")
|
||||||
|
if m := starterModel(big, 16384, 0, "http://x/q.gguf", nil, true); m.Profiles[0].MinMemoryGB != 11 ||
|
||||||
|
!slices.Equal(m.Profiles[0].Args, []string{"--ctx-size", "65536"}) {
|
||||||
|
t.Errorf("a 9 GB starter asks for %d GB, %v", m.Profiles[0].MinMemoryGB, m.Profiles[0].Args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheChoicesAreTheirFlagsAgain(t *testing.T) {
|
||||||
|
in := &serverInit{admin: "ops@example.com", hosts: []string{"llm.example.com"}, postgres: true,
|
||||||
|
mirrorPath: "/data/models", noNode: true, language: "tr", media: true,
|
||||||
|
webhooks: []string{"https://hooks.slack.com/services/T/B/x, json:https://a.example.com/in"},
|
||||||
|
judge: judgeSpec{Categories: []string{"Violent", "Jailbreak"}}, maxKeyTTL: 7 * 24 * time.Hour}
|
||||||
|
in.starter, _ = starterFrom("qwen3-4b")
|
||||||
|
got := strings.Join(in.initFlags(), " ")
|
||||||
|
want := "--admin ops@example.com --host llm.example.com --language tr --max-key-ttl 168h --postgres " +
|
||||||
|
"--mirror-dir /data/models --no-node --starter qwen3-4b --judge-categories Violent,Jailbreak " +
|
||||||
|
"--webhook https://hooks.slack.com/services/T/B/x --webhook json:https://a.example.com/in --media"
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("flags\n got %s\nwant %s", got, want)
|
||||||
|
}
|
||||||
|
if got := strings.Join((&serverInit{}).initFlags(), " "); got != "" {
|
||||||
|
t.Errorf("nothing chosen is %q", got)
|
||||||
|
}
|
||||||
|
if w := shellWord("Non-violent Illegal Acts,PII"); w != "'Non-violent Illegal Acts,PII'" {
|
||||||
|
t.Errorf("quoted %s", w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestChoicesThatCannotBeInstalledAreRefused(t *testing.T) {
|
||||||
|
for _, in := range []serverInit{
|
||||||
|
{language: "de"},
|
||||||
|
{judge: judgeSpec{Fail: "maybe"}},
|
||||||
|
{judge: judgeSpec{Categories: []string{"Rudeness"}}},
|
||||||
|
{judge: judgeSpec{Replicas: 9}},
|
||||||
|
{starterCtx: 100},
|
||||||
|
{maxKeyTTL: time.Minute},
|
||||||
|
{vip: "10.0.0.160/24"},
|
||||||
|
{standbys: true, vip: "10.0.0.160"},
|
||||||
|
} {
|
||||||
|
if err := in.checkChoices(); err == nil {
|
||||||
|
t.Errorf("taken: %+v", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := (&serverInit{standbys: true, vip: "10.0.0.160/24"}).checkChoices(); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWithStandbysTheMirrorAnswersEveryControlPlane(t *testing.T) {
|
||||||
|
in := &serverInit{hosts: []string{"10.0.0.5"}, patroniNodes: []pgha.Node{
|
||||||
|
{Member: pgha.Member{Name: "db1", IP: "10.0.0.6"}}, {Member: pgha.Member{Name: "db2", IP: "10.0.0.7"}},
|
||||||
|
}}
|
||||||
|
if u := in.mirrorUnit(); !strings.Contains(u, "--api-listen 127.0.0.1:8082") || strings.Contains(u, "--api-allow") {
|
||||||
|
t.Errorf("one control plane: the API is for this machine only:\n%s", u)
|
||||||
|
}
|
||||||
|
in.standbys = true
|
||||||
|
u := in.mirrorUnit()
|
||||||
|
for _, want := range []string{"--api-listen :8082", "--api-allow 10.0.0.5", "--api-allow 10.0.0.6", "--api-allow 10.0.0.7"} {
|
||||||
|
if !strings.Contains(u, want) {
|
||||||
|
t.Errorf("the unit lacks %q:\n%s", want, u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := in.mirrorAPIURL(); got != "http://10.0.0.5:8082" {
|
||||||
|
t.Errorf("the control planes reach the mirror at %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheFloatingAddressIsInTheCertificate(t *testing.T) {
|
||||||
|
in := &serverInit{vip: "10.0.0.160/24"}
|
||||||
|
if !slices.Contains(in.certHosts(), "10.0.0.160") {
|
||||||
|
t.Errorf("hosts %v", in.certHosts())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStandbysWantTheStoresMachines(t *testing.T) {
|
||||||
|
in := &serverInit{standbys: true, out: &strings.Builder{}}
|
||||||
|
if err := in.ensureStandbys(context.Background()); err == nil || !strings.Contains(err.Error(), "--patroni") {
|
||||||
|
t.Errorf("standbys with nowhere to go: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTwoClustersOnOneNetworkKeepApartFloatingAddresses(t *testing.T) {
|
||||||
|
a, b := vrrpRouterID("10.0.0.161"), vrrpRouterID("10.0.0.164")
|
||||||
|
if a == b || a < 1 || a > 255 || b < 1 || b > 255 {
|
||||||
|
t.Errorf("router ids %d and %d", a, b)
|
||||||
|
}
|
||||||
|
conf := keepalivedConf("ens18", "10.0.0.164", 24, 100, "secret12")
|
||||||
|
if !strings.Contains(conf, fmt.Sprintf("virtual_router_id %d\n", b)) || !strings.Contains(conf, "interface ens18") {
|
||||||
|
t.Errorf("conf:\n%s", conf)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheJudgesAndTheStarterShareTheCPUsRatherThanEachTakingAll(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
in serverInit
|
||||||
|
judge, starter int
|
||||||
|
}{
|
||||||
|
// One machine: one judge, on up to four cores; the starter has the rest.
|
||||||
|
{serverInit{cpus: 4}, 4, 0},
|
||||||
|
{serverInit{cpus: 8}, 4, 4},
|
||||||
|
{serverInit{cpus: 32}, 4, 8},
|
||||||
|
{serverInit{cpus: 2}, 2, 0},
|
||||||
|
{serverInit{cpus: 1}, 0, 0},
|
||||||
|
{serverInit{cpus: 4, noGuardModel: true}, judgeCores, 4}, // no judge: all for the starter
|
||||||
|
// Two asked for on one machine: two cores each, one each where two
|
||||||
|
// would not fit both.
|
||||||
|
{serverInit{cpus: 8, judge: judgeSpec{Replicas: 2}}, 2, 4},
|
||||||
|
{serverInit{cpus: 2, judge: judgeSpec{Replicas: 2}}, 1, 0},
|
||||||
|
{serverInit{cpus: 8, judge: judgeSpec{Replicas: 3}}, 2, 2},
|
||||||
|
{serverInit{cpus: 4, noNode: true}, 2, 0}, // on nodes not known yet
|
||||||
|
} {
|
||||||
|
got := c.in.cpuPlan()
|
||||||
|
if got.judge != c.judge || got.starter != c.starter {
|
||||||
|
t.Errorf("%+v: judge %d starter %d, want %d %d", c.in, got.judge, got.starter, c.judge, c.starter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m := guardModel("http://x/g.gguf", 2, 1, nil)
|
||||||
|
if p := m.Profiles[0]; p.CPUCores != 1 || p.MinMemoryGB != 2 || !slices.Contains(p.Args, "--cache-ram") {
|
||||||
|
t.Errorf("judge profile: %d cores, %d GB, args %v", p.CPUCores, p.MinMemoryGB, p.Args)
|
||||||
|
}
|
||||||
|
s := starterModel(starterChoices[0], defaultContext, 2, "http://x/q.gguf", []string{"6000-24g"}, true)
|
||||||
|
if cpu := s.Profiles[len(s.Profiles)-1]; cpu.Name != "cpu" || cpu.CPUCores != 2 || s.Profiles[0].CPUCores != 0 {
|
||||||
|
t.Errorf("starter: GPU profile %d cores, CPU %+v", s.Profiles[0].CPUCores, cpu)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A second judge on the same machine shares its cores and is lost with it:
|
||||||
|
// one machine has one, more machines two.
|
||||||
|
func TestOneMachineHasOneJudgeAndMoreHaveTwo(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
in serverInit
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{serverInit{}, 1},
|
||||||
|
{serverInit{noNode: true}, 2},
|
||||||
|
{serverInit{patroni: []string{"a@db1", "b@db2"}}, 2},
|
||||||
|
{serverInit{standbys: true}, 2},
|
||||||
|
{serverInit{judge: judgeSpec{Replicas: 3}}, 3},
|
||||||
|
} {
|
||||||
|
if got := c.in.judgeFor().Replicas; got != c.want {
|
||||||
|
t.Errorf("%+v: %d judges, want %d", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The defaults are left out of the command; a choice against them is in.
|
||||||
|
ha := serverInit{noNode: true, patroni: []string{"a@db1", "b@db2"}, judge: judgeSpec{Replicas: 2}}
|
||||||
|
if f := strings.Join(ha.initFlags(), " "); strings.Contains(f, "--judge-replicas") {
|
||||||
|
t.Errorf("two judges on three machines named: %s", f)
|
||||||
|
}
|
||||||
|
one := serverInit{judge: judgeSpec{Replicas: 2}}
|
||||||
|
if f := strings.Join(one.initFlags(), " "); !strings.Contains(f, "--judge-replicas 2") {
|
||||||
|
t.Errorf("two judges on one machine not named: %s", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -120,10 +122,36 @@ func (in *serverInit) writeUnit(path, body string) (bool, error) {
|
|||||||
|
|
||||||
func (in *serverInit) mirrorUnit() string {
|
func (in *serverInit) mirrorUnit() string {
|
||||||
base := "http://" + net.JoinHostPort(in.reachableAddr(), "8081")
|
base := "http://" + net.JoinHostPort(in.reachableAddr(), "8081")
|
||||||
return mirrorUnitFor(mirrorDir, mirrorAPI, base, nil,
|
if in.standbys {
|
||||||
|
return mirrorUnitFor(in.mirrorRoot(), ":8082", base, in.mirrorAllow(),
|
||||||
|
"'llmgw server init'. Bytes on :8081 for every node; the\n# management API for the control planes only, whichever leads.")
|
||||||
|
}
|
||||||
|
return mirrorUnitFor(in.mirrorRoot(), mirrorAPI, base, nil,
|
||||||
"'llmgw server init'. Bytes on :8081 for every node; the\n# management API on loopback only, for the control plane beside it.")
|
"'llmgw server init'. Bytes on :8081 for every node; the\n# management API on loopback only, for the control plane beside it.")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// localMirrorAPI is the management API of the mirror beside this control
|
||||||
|
// plane: on loopback, or, with standbys that may lead instead, on an address
|
||||||
|
// they reach.
|
||||||
|
func (in *serverInit) localMirrorAPI() string {
|
||||||
|
if in.standbys {
|
||||||
|
return "http://" + net.JoinHostPort(in.reachableAddr(), "8082")
|
||||||
|
}
|
||||||
|
return "http://" + mirrorAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
// mirrorAllow are the control planes the mirror's management API answers
|
||||||
|
// besides loopback: this one by its own address, and each standby.
|
||||||
|
func (in *serverInit) mirrorAllow() []string {
|
||||||
|
out := []string{in.reachableAddr()}
|
||||||
|
for _, n := range in.patroniNodes {
|
||||||
|
if !slices.Contains(out, n.IP) {
|
||||||
|
out = append(out, n.IP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// mirrorUnitFor is a mirror's systemd unit. allow is who, besides the
|
// mirrorUnitFor is a mirror's systemd unit. allow is who, besides the
|
||||||
// machine itself, may use the management API.
|
// machine itself, may use the management API.
|
||||||
func mirrorUnitFor(dir, apiListen, base string, allow []string, about string) string {
|
func mirrorUnitFor(dir, apiListen, base string, allow []string, about string) string {
|
||||||
@@ -160,7 +188,7 @@ func (in *serverInit) mirrorAPIURL() string {
|
|||||||
if in.mirrorURL != "" {
|
if in.mirrorURL != "" {
|
||||||
return in.mirrorURL
|
return in.mirrorURL
|
||||||
}
|
}
|
||||||
return "http://" + mirrorAPI
|
return in.localMirrorAPI()
|
||||||
}
|
}
|
||||||
|
|
||||||
// useMirror points the control plane at a mirror on another machine, set up
|
// useMirror points the control plane at a mirror on another machine, set up
|
||||||
@@ -202,15 +230,16 @@ func (in *serverInit) ensureMirror(ctx context.Context) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if cfg.Mirror.API != "" && cfg.Mirror.API != "http://"+mirrorAPI {
|
if cfg.Mirror.API != "" && cfg.Mirror.API != "http://"+mirrorAPI && cfg.Mirror.API != in.localMirrorAPI() {
|
||||||
in.kept("mirror at %s, as server.yaml says", cfg.Mirror.API)
|
in.kept("mirror at %s, as server.yaml says", cfg.Mirror.API)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
|
dir := in.mirrorRoot()
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := in.chown(mirrorDir, true); err != nil {
|
if err := in.chown(dir, true); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
changed, err := in.writeUnit(mirrorUnitPath, in.mirrorUnit())
|
changed, err := in.writeUnit(mirrorUnitPath, in.mirrorUnit())
|
||||||
@@ -227,21 +256,25 @@ func (in *serverInit) ensureMirror(ctx context.Context) error {
|
|||||||
if err := in.systemctl(ctx, "restart", "llmgw-mirror"); err != nil {
|
if err := in.systemctl(ctx, "restart", "llmgw-mirror"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
in.done("model mirror in %s, serving on :8081", mirrorDir)
|
in.done("model mirror in %s, serving on :8081", dir)
|
||||||
} else {
|
} else {
|
||||||
in.kept("model mirror in %s", mirrorDir)
|
in.kept("model mirror in %s", dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.Mirror.API == "" {
|
if cfg.Mirror.API == "" {
|
||||||
if err := appendMirrorBlockFor(in.configPath(), "http://"+mirrorAPI,
|
comment := "# The model mirror on this machine, added by 'llmgw server init'. Its\n" +
|
||||||
"# The model mirror on this machine, added by 'llmgw server init'. Its\n"+
|
"# management API is on loopback, so it needs no certificate."
|
||||||
"# management API is on loopback, so it needs no certificate."); err != nil {
|
if in.standbys {
|
||||||
|
comment = "# The model mirror on this machine, added by 'llmgw server init'. Its\n" +
|
||||||
|
"# management API answers the control planes' addresses only."
|
||||||
|
}
|
||||||
|
if err := appendMirrorBlockFor(in.configPath(), in.localMirrorAPI(), comment); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
in.done("server.yaml points at the mirror")
|
in.done("server.yaml points at the mirror")
|
||||||
}
|
}
|
||||||
|
|
||||||
m, err := mirror.NewClient("http://"+mirrorAPI, tlsconf.Config{}, 5*time.Second)
|
m, err := mirror.NewClient(in.localMirrorAPI(), tlsconf.Config{}, 5*time.Second)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -414,8 +447,9 @@ func hasNVIDIADevice() bool {
|
|||||||
// --- the starter model ---
|
// --- the starter model ---
|
||||||
|
|
||||||
// starterModel is the catalog entry: a GPU profile first when there is a card,
|
// starterModel is the catalog entry: a GPU profile first when there is a card,
|
||||||
// and a CPU profile always, both pinned to this machine.
|
// and a CPU profile always, both pinned to this machine. ctx is the context a
|
||||||
func starterModel(source string, gpuClasses []string, pinned bool) catalog.Model {
|
// client may use; each engine holds four times that, for four at once.
|
||||||
|
func starterModel(spec starterSpec, ctx, cores int, source string, gpuClasses []string, pinned bool) catalog.Model {
|
||||||
var pin map[string]string
|
var pin map[string]string
|
||||||
if pinned {
|
if pinned {
|
||||||
pin = map[string]string{localLabelKey: localLabelValue}
|
pin = map[string]string{localLabelKey: localLabelValue}
|
||||||
@@ -424,21 +458,25 @@ func starterModel(source string, gpuClasses []string, pinned bool) catalog.Model
|
|||||||
for _, class := range gpuClasses {
|
for _, class := range gpuClasses {
|
||||||
profiles = append(profiles, catalog.Profile{
|
profiles = append(profiles, catalog.Profile{
|
||||||
Name: "gpu-" + class, Engine: "llamacpp", GPUClass: class,
|
Name: "gpu-" + class, Engine: "llamacpp", GPUClass: class,
|
||||||
Priority: 200, MinMemoryGB: 2, NodeSelector: pin,
|
Priority: 200, MinMemoryGB: spec.minMemoryGB(), NodeSelector: pin,
|
||||||
Args: []string{"--ctx-size", "32768"},
|
Args: []string{"--ctx-size", strconv.Itoa(max(32768, 4*ctx))},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
profiles = append(profiles, catalog.Profile{
|
profiles = append(profiles, catalog.Profile{
|
||||||
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: 2, NodeSelector: pin,
|
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: spec.minMemoryGB(), CPUCores: cores, NodeSelector: pin,
|
||||||
Args: []string{"--ctx-size", "16384"},
|
Args: []string{"--ctx-size", strconv.Itoa(max(16384, 4*ctx))},
|
||||||
})
|
})
|
||||||
|
var aliases []string
|
||||||
|
if spec.Alias != "" {
|
||||||
|
aliases = []string{spec.Alias}
|
||||||
|
}
|
||||||
return catalog.Model{
|
return catalog.Model{
|
||||||
Name: starterName,
|
Name: spec.Name,
|
||||||
Aliases: []string{starterAlias},
|
Aliases: aliases,
|
||||||
Description: "A small model init put here so there is something to try; fast, not clever.",
|
Description: "The model init put here so there is something to try: " + spec.About + ".",
|
||||||
Source: source,
|
Source: source,
|
||||||
RoutingProfile: "chat",
|
RoutingProfile: "chat",
|
||||||
ContextLength: 4096,
|
ContextLength: ctx,
|
||||||
MaxOutput: 1024,
|
MaxOutput: 1024,
|
||||||
Scaling: catalog.Scaling{Min: 1, Max: 1},
|
Scaling: catalog.Scaling{Min: 1, Max: 1},
|
||||||
Profiles: profiles,
|
Profiles: profiles,
|
||||||
@@ -446,8 +484,8 @@ func starterModel(source string, gpuClasses []string, pinned bool) catalog.Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
// guardModel is the judge's catalog entry: CPU only, so it takes no card from
|
// guardModel is the judge's catalog entry: CPU only, so it takes no card from
|
||||||
// the models people use, on whichever node has room.
|
// the models people use, on whichever node has room; replicas copies of it.
|
||||||
func guardModel(source string) catalog.Model {
|
func guardModel(source string, replicas, cores int, selector map[string]string) catalog.Model {
|
||||||
return catalog.Model{
|
return catalog.Model{
|
||||||
Name: guardModelName,
|
Name: guardModelName,
|
||||||
Description: "The guardrails' judge: reads prompts and answers for unsafe content. Not offered to clients.",
|
Description: "The guardrails' judge: reads prompts and answers for unsafe content. Not offered to clients.",
|
||||||
@@ -456,10 +494,17 @@ func guardModel(source string) catalog.Model {
|
|||||||
Guard: &catalog.GuardModel{Format: "qwen3guard"},
|
Guard: &catalog.GuardModel{Format: "qwen3guard"},
|
||||||
// Two: every judged request waits on it, and with fail: closed a
|
// Two: every judged request waits on it, and with fail: closed a
|
||||||
// judge that is not there refuses everything.
|
// judge that is not there refuses everything.
|
||||||
Scaling: catalog.Scaling{Min: 2, Max: 2},
|
Scaling: catalog.Scaling{Min: replicas, Max: replicas},
|
||||||
Profiles: []catalog.Profile{{
|
Profiles: []catalog.Profile{{
|
||||||
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: 2,
|
// About 1.6 GB with its context, which stays so with no prompt
|
||||||
Args: []string{"--ctx-size", "8192"},
|
// cache: llama.cpp keeps up to 8 GB of prompts by default, and a
|
||||||
|
// judge's, each a different text, are not asked again. With the
|
||||||
|
// cache one grew to 3.2 GB over a day of traffic; reserving 4 for
|
||||||
|
// that left a 7 GB machine room for only one of the two.
|
||||||
|
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: judgeMemoryGB, CPUCores: cores,
|
||||||
|
Args: []string{"--ctx-size", "8192", "--cache-ram", "0"},
|
||||||
|
// The judge machines, when the install was given some.
|
||||||
|
NodeSelector: selector,
|
||||||
}},
|
}},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -469,15 +514,21 @@ func (in *serverInit) ensureStarter(ctx context.Context) error {
|
|||||||
// Served here when this machine is a node, pinned so it does not wander
|
// Served here when this machine is a node, pinned so it does not wander
|
||||||
// to the first GPU node that joins; otherwise on whichever node joins.
|
// to the first GPU node that joins; otherwise on whichever node joins.
|
||||||
pinned := !in.noNode
|
pinned := !in.noNode
|
||||||
return in.ensureServed(ctx, starterName, starterSource, starterVersion, pinned,
|
spec, n := in.starterModelSpec(), in.starterContext()
|
||||||
func(url string, classes []string) catalog.Model { return starterModel(url, classes, pinned) })
|
return in.ensureServed(ctx, spec.Name, spec.Source, starterVersion, pinned,
|
||||||
|
func(url string, classes []string) catalog.Model {
|
||||||
|
return starterModel(spec, n, in.cpuPlan().starter, url, classes, pinned)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureGuardModel fetches the guardrails' judge model into the mirror and
|
// ensureGuardModel fetches the guardrails' judge model into the mirror and
|
||||||
// adds it to the catalog, to run on any node with room.
|
// adds it to the catalog, to run on any node with room.
|
||||||
func (in *serverInit) ensureGuardModel(ctx context.Context) error {
|
func (in *serverInit) ensureGuardModel(ctx context.Context) error {
|
||||||
return in.ensureServed(ctx, guardModelName, guardModelSource, guardModelVersion, false,
|
return in.ensureServed(ctx, guardModelName, guardModelSource, guardModelVersion, false,
|
||||||
func(url string, _ []string) catalog.Model { return guardModel(url) })
|
func(url string, _ []string) catalog.Model {
|
||||||
|
cores, selector := in.judgePlacement()
|
||||||
|
return guardModel(url, in.judgeFor().Replicas, cores, selector)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureServed fetches a model into the mirror, adds it to the catalog, and
|
// ensureServed fetches a model into the mirror, adds it to the catalog, and
|
||||||
@@ -661,3 +712,55 @@ func waitFor(ctx context.Context, limit time.Duration, what string, done func()
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cpuSplit is the cores init's models reserve: each judge replica's, and
|
||||||
|
// the starter's on CPU.
|
||||||
|
type cpuSplit struct{ judge, starter int }
|
||||||
|
|
||||||
|
// cpuPlan divides the CPUs between the judges and the starter, so that the
|
||||||
|
// engines do not each take every core. Without it, two judge replicas and
|
||||||
|
// the starter on one 4-core control plane all ran on all four: under load
|
||||||
|
// most judgments missed their budget and the requests were refused.
|
||||||
|
//
|
||||||
|
// A judge needs cores: a judgment of about 280 tokens took 8.0 s on one,
|
||||||
|
// 4.2 s on two and 2.3 s on four, against a 5 s budget. On one machine its
|
||||||
|
// one replica gets up to four; where there are two, two each (one each only
|
||||||
|
// where two would not fit both). The starter has what is left, up to 8, or
|
||||||
|
// no reservation at all when nothing is, rather than a starter that fits
|
||||||
|
// nowhere. A machine that is not the node reserves two a judge on whichever
|
||||||
|
// node takes it, and the starter, on a node not known yet, none.
|
||||||
|
func (in *serverInit) cpuPlan() cpuSplit {
|
||||||
|
if in.noNode {
|
||||||
|
return cpuSplit{judge: judgeCores}
|
||||||
|
}
|
||||||
|
n := in.cpus
|
||||||
|
if n <= 0 {
|
||||||
|
n = runtime.NumCPU()
|
||||||
|
}
|
||||||
|
if n < 2 {
|
||||||
|
return cpuSplit{}
|
||||||
|
}
|
||||||
|
judges := 0
|
||||||
|
if !in.noGuardModel {
|
||||||
|
judges = in.judgeFor().Replicas
|
||||||
|
}
|
||||||
|
judge := judgeCores
|
||||||
|
switch {
|
||||||
|
case judges == 1:
|
||||||
|
judge = min(n, judgeCoresAlone)
|
||||||
|
case judges > 0 && judges*judge > n:
|
||||||
|
judge = max(1, n/judges)
|
||||||
|
}
|
||||||
|
left := max(n-judges*judge, 0)
|
||||||
|
return cpuSplit{judge: judge, starter: min(left, 8)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeCores and judgeMemoryGB are what a judge replica needs: two cores
|
||||||
|
// for a judgment within its budget, and 2 GB for its 1.6 with no prompt
|
||||||
|
// cache. judgeCoresAlone is what the one judge of a single machine takes,
|
||||||
|
// past which a model this small hardly gets faster.
|
||||||
|
const (
|
||||||
|
judgeCores = 2
|
||||||
|
judgeCoresAlone = 4
|
||||||
|
judgeMemoryGB = 2
|
||||||
|
)
|
||||||
@@ -28,7 +28,7 @@ func TestTheInitAgentUnitIsTheShippedUnit(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTheStarterUsesTheGPUAndFallsBackToCPUOnThisMachine(t *testing.T) {
|
func TestTheStarterUsesTheGPUAndFallsBackToCPUOnThisMachine(t *testing.T) {
|
||||||
m := starterModel("http://10.0.0.5:8081/qwen2.5-0.5b-instruct/starter/q.gguf", []string{"4090-24g"}, true)
|
m := starterModel(starterChoices[0], defaultContext, 0, "http://10.0.0.5:8081/qwen2.5-0.5b-instruct/starter/q.gguf", []string{"4090-24g"}, true)
|
||||||
c, err := catalog.New([]catalog.Model{m})
|
c, err := catalog.New([]catalog.Model{m})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("the starter is not a valid catalog entry: %v", err)
|
t.Fatalf("the starter is not a valid catalog entry: %v", err)
|
||||||
@@ -53,7 +53,7 @@ func TestTheStarterUsesTheGPUAndFallsBackToCPUOnThisMachine(t *testing.T) {
|
|||||||
t.Error("the starter should be running, not waiting to be asked for")
|
t.Error("the starter should be running, not waiting to be asked for")
|
||||||
}
|
}
|
||||||
|
|
||||||
if m := starterModel("http://x/q.gguf", nil, true); len(m.Profiles) != 1 || m.Profiles[0].Name != "cpu" {
|
if m := starterModel(starterChoices[0], defaultContext, 0, "http://x/q.gguf", nil, true); len(m.Profiles) != 1 || m.Profiles[0].Name != "cpu" {
|
||||||
t.Errorf("without a GPU only the CPU profile: %+v", m.Profiles)
|
t.Errorf("without a GPU only the CPU profile: %+v", m.Profiles)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,7 +96,7 @@ func TestTheMirrorUnitAdvertisesAnAddressNodesCanReach(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestWithNoNodeHereTheStarterGoesToTheFirstNodeThatJoins(t *testing.T) {
|
func TestWithNoNodeHereTheStarterGoesToTheFirstNodeThatJoins(t *testing.T) {
|
||||||
m := starterModel("http://x/q.gguf", nil, false)
|
m := starterModel(starterChoices[0], defaultContext, 0, "http://x/q.gguf", nil, false)
|
||||||
for _, p := range m.Profiles {
|
for _, p := range m.Profiles {
|
||||||
if len(p.NodeSelector) > 0 {
|
if len(p.NodeSelector) > 0 {
|
||||||
t.Errorf("profile %s is pinned to a machine that is not a node: %v", p.Name, p.NodeSelector)
|
t.Errorf("profile %s is pinned to a machine that is not a node: %v", p.Name, p.NodeSelector)
|
||||||
@@ -171,7 +171,7 @@ func TestTheInstalledConfigurationIsHardenedAndLoads(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestTheGuardModelIsAValidJudge(t *testing.T) {
|
func TestTheGuardModelIsAValidJudge(t *testing.T) {
|
||||||
m := guardModel("http://10.0.0.5:8081/qwen3guard-0.6b/guard/g.gguf")
|
m := guardModel("http://10.0.0.5:8081/qwen3guard-0.6b/guard/g.gguf", 2, 2, nil)
|
||||||
c, err := catalog.New([]catalog.Model{m})
|
c, err := catalog.New([]catalog.Model{m})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("not a valid catalog entry: %v", err)
|
t.Fatalf("not a valid catalog entry: %v", err)
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ func (in *serverInit) storeFirewall(ctx context.Context, members []pgha.Node, se
|
|||||||
} else {
|
} else {
|
||||||
in.kept("firewall on %s", m.Name)
|
in.kept("firewall on %s", m.Name)
|
||||||
}
|
}
|
||||||
|
// And after, as the cluster's settings say, read from the
|
||||||
|
// member's own copy of the store.
|
||||||
|
if err := firewall.InstallFollower(ctx, m.Remote); err != nil {
|
||||||
|
fmt.Fprintf(in.out, " !! firewall on %s: %v; the cluster's settings will not change it\n", m.Name, err)
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,5 +140,5 @@ func loopbackListen(listen string) bool {
|
|||||||
// ensureFirewallSync installs the units that apply the cluster's firewall
|
// ensureFirewallSync installs the units that apply the cluster's firewall
|
||||||
// setting on this control plane.
|
// setting on this control plane.
|
||||||
func (in *serverInit) ensureFirewallSync(ctx context.Context) error {
|
func (in *serverInit) ensureFirewallSync(ctx context.Context) error {
|
||||||
return installFirewallSync(ctx, firewall.Local{})
|
return installFirewallSync(ctx, firewall.Local{}, firewallStateFile)
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/placement"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Machines of the judges' own. Under load on a 4-core control plane that was
|
||||||
|
// also a node, 38 of 60 requests were refused for want of a judgment; with the
|
||||||
|
// judges on a node of their own the same test refused 2. A judge machine is
|
||||||
|
// added as a node carrying judgeRoleLabel, and the judge's profile selects
|
||||||
|
// that label, so nothing else is placed beside it and it on nothing else.
|
||||||
|
|
||||||
|
// judgeRoleLabel marks a node as the judges'.
|
||||||
|
const (
|
||||||
|
judgeRoleLabel = placement.RoleLabel
|
||||||
|
judgeRoleValue = "judge"
|
||||||
|
)
|
||||||
|
|
||||||
|
// judgeMachine is a machine for the judges: user@host, and the connection the
|
||||||
|
// setup screen already made to it, when it made one.
|
||||||
|
type judgeMachine struct {
|
||||||
|
target string
|
||||||
|
remote onboard.Remote
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureJudgeNodes adds each judge machine as a node labelled for the
|
||||||
|
// judges, and says how many cores the smallest of them has.
|
||||||
|
func (in *serverInit) ensureJudgeNodes(ctx context.Context) error {
|
||||||
|
if len(in.judgeMachines) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
na := nodeAdd{serverCfg: in.configPath(), plain: true}
|
||||||
|
na.opts.PKI = in.pkiDir
|
||||||
|
na.opts.NoFirewall = in.noFirewall
|
||||||
|
if err := na.prepare(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.judgeCPUs = nil
|
||||||
|
for _, jm := range in.judgeMachines {
|
||||||
|
o := na.target(jm.target)
|
||||||
|
fmt.Fprintf(in.out, " .. adding %s as a node for the judges\n", o.Addr)
|
||||||
|
r := onboard.New(o, nil)
|
||||||
|
if jm.remote != nil {
|
||||||
|
r.Remote = jm.remote
|
||||||
|
}
|
||||||
|
res, err := r.Run(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("%s: %w", o.Addr, err)
|
||||||
|
}
|
||||||
|
if err := na.watch(ctx, res.Node, func(onboard.Event) {}); err != nil && err != errNoAdmin {
|
||||||
|
return fmt.Errorf("%s: %w", res.Node, err)
|
||||||
|
}
|
||||||
|
if out, err := r.Remote.Run(ctx, "nproc", nil); err == nil {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
|
||||||
|
in.judgeCPUs = append(in.judgeCPUs, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := changeStoredSettings(in.configPath(), "a node for the judges, from init", func(doc *settings.Doc) error {
|
||||||
|
return doc.SetYAML([]string{"agents", "nodes", res.Node, "labels", judgeRoleLabel}, judgeRoleValue)
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("labelling %s for the judges: %w", res.Node, err)
|
||||||
|
}
|
||||||
|
in.done("%s is a node for the judges alone (%s: %s)", res.Node, judgeRoleLabel, judgeRoleValue)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgePlacement is the judge's cores and where it may run: on the judge
|
||||||
|
// machines when there are some, by init's rule over their cores; otherwise
|
||||||
|
// on any node, with this machine's plan.
|
||||||
|
func (in *serverInit) judgePlacement() (cores int, selector map[string]string) {
|
||||||
|
if len(in.judgeMachines) == 0 {
|
||||||
|
return in.cpuPlan().judge, nil
|
||||||
|
}
|
||||||
|
return refitCores(in.judgeFor().Replicas, in.judgeCPUs), map[string]string{judgeRoleLabel: judgeRoleValue}
|
||||||
|
}
|
||||||
|
|
||||||
|
// judgeMachineFlag is --judge-node, given again for each machine.
|
||||||
|
type judgeMachineFlag struct{ to *[]judgeMachine }
|
||||||
|
|
||||||
|
func (f judgeMachineFlag) String() string {
|
||||||
|
if f.to == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var out []string
|
||||||
|
for _, m := range *f.to {
|
||||||
|
out = append(out, m.target)
|
||||||
|
}
|
||||||
|
return strings.Join(out, ",")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f judgeMachineFlag) Set(v string) error {
|
||||||
|
if strings.TrimSpace(v) == "" {
|
||||||
|
return fmt.Errorf("--judge-node wants user@host")
|
||||||
|
}
|
||||||
|
*f.to = append(*f.to, judgeMachine{target: strings.TrimSpace(v)})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (judgeMachineFlag) Type() string { return "user@host" }
|
||||||
|
|
||||||
|
// judgeReadyWait is how long init waits for its judge to load before timing
|
||||||
|
// it: the weights come from the mirror, and llama.cpp loads in seconds.
|
||||||
|
const judgeReadyWait = 15 * time.Minute
|
||||||
|
|
||||||
|
// checkJudge times the judge once it is up, and keeps what it found for the
|
||||||
|
// closing text: a machine too slow for its judge is said before its first
|
||||||
|
// refused request, not after. The budget is not changed without asking.
|
||||||
|
func (in *serverInit) checkJudge(ctx context.Context) {
|
||||||
|
c := in.adminAPI()
|
||||||
|
deadline := time.Now().Add(judgeReadyWait)
|
||||||
|
for {
|
||||||
|
var judges []adminapi.JudgeView
|
||||||
|
if err := c.get(ctx, "/api/v1/guard/judges", &judges); err == nil {
|
||||||
|
ready := false
|
||||||
|
for _, j := range judges {
|
||||||
|
ready = ready || (j.Model == guardModelName && j.Ready > 0)
|
||||||
|
}
|
||||||
|
if ready {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) || ctx.Err() != nil {
|
||||||
|
in.judgeNote = " !! the judge was not up to be timed; once it is: llmgw ctl guard judge check\n"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Second)
|
||||||
|
}
|
||||||
|
fmt.Fprintf(in.out, " .. timing the judge\n")
|
||||||
|
v, err := checkJudge(ctx, c, guardModelName, 0)
|
||||||
|
if err != nil {
|
||||||
|
in.judgeNote = " !! the judge could not be timed (" + err.Error() + "); later: llmgw ctl guard judge check\n"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if v.Verdict == "ok" {
|
||||||
|
in.done("the judge reads a prompt in %.1f s, of the %.1f s a request waits; about %.1f requests a second",
|
||||||
|
float64(v.SlowestMs)/1000, float64(v.BudgetMs)/1000, v.PerSecond/2)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
in.judgeNote = "The judge is " + v.Verdict + " on this cluster:\n" + indent(judgeCheckText(v), " ") + "\n"
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestJudgesGoOnTheirOwnMachinesWhenTheInstallHasSome(t *testing.T) {
|
||||||
|
in := newDefaultServerInit()
|
||||||
|
if _, sel := in.judgePlacement(); sel != nil {
|
||||||
|
t.Errorf("no judge machines, yet a selector: %v", sel)
|
||||||
|
}
|
||||||
|
if err := (judgeMachineFlag{&in.judgeMachines}).Set("ubuntu@10.0.0.21"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = (judgeMachineFlag{&in.judgeMachines}).Set("ubuntu@10.0.0.22")
|
||||||
|
in.judge.Replicas, in.judgeCPUs = 2, []int{4, 4}
|
||||||
|
cores, sel := in.judgePlacement()
|
||||||
|
if sel[judgeRoleLabel] != judgeRoleValue || cores != refitCores(2, []int{4, 4}) {
|
||||||
|
t.Errorf("cores %d, selector %v", cores, sel)
|
||||||
|
}
|
||||||
|
m := guardModel("http://x/g.gguf", 2, cores, sel)
|
||||||
|
if m.Profiles[0].NodeSelector[judgeRoleLabel] != judgeRoleValue {
|
||||||
|
t.Errorf("the judge's profile does not select its machines: %+v", m.Profiles[0])
|
||||||
|
}
|
||||||
|
// The same from a script.
|
||||||
|
flags := strings.Join(in.initFlags(), " ")
|
||||||
|
if !strings.Contains(flags, "--judge-node ubuntu@10.0.0.21") || !strings.Contains(flags, "--judge-node ubuntu@10.0.0.22") {
|
||||||
|
t.Errorf("flags %s", flags)
|
||||||
|
}
|
||||||
|
if err := (judgeMachineFlag{&in.judgeMachines}).Set(" "); err == nil {
|
||||||
|
t.Error("an empty --judge-node was taken")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheSetupAsksForJudgeMachinesWhenChosen(t *testing.T) {
|
||||||
|
m := newSetupModel("ctl", []string{"10.0.0.5"})
|
||||||
|
m.choice, m.page = 1, pMachines
|
||||||
|
m.mfocus = m.shown() * mfCount // the judges' choice
|
||||||
|
m = press(m, "right").(setupModel)
|
||||||
|
if m.judges != 1 || m.shown() != 3 || m.mfocus != 3*mfCount {
|
||||||
|
t.Fatalf("judges %d, shown %d, focus %d", m.judges, m.shown(), m.mfocus)
|
||||||
|
}
|
||||||
|
if v := m.machinesView(); !strings.Contains(v, "Judge machine 1") || !strings.Contains(v, "Check the 4 machines") {
|
||||||
|
t.Errorf("the page lacks the judge machine:\n%s", v)
|
||||||
|
}
|
||||||
|
for i, addr := range []string{"10.0.0.11", "10.0.0.12", ""} {
|
||||||
|
m.db[i].addr.SetValue(addr)
|
||||||
|
m.db[i].user.SetValue("ubuntu")
|
||||||
|
m.db[i].auth = authAgent
|
||||||
|
}
|
||||||
|
if err := m.validateMachines(); !strings.Contains(err, "judge machine 1 has no address") {
|
||||||
|
t.Errorf("err %q", err)
|
||||||
|
}
|
||||||
|
m.db[2].addr.SetValue("10.0.0.21")
|
||||||
|
if err := m.validateMachines(); err != "" {
|
||||||
|
t.Errorf("err %q", err)
|
||||||
|
}
|
||||||
|
if cmd := m.command(); !strings.Contains(cmd, "--judge-node ubuntu@10.0.0.21") || strings.Contains(cmd, "--patroni ubuntu@10.0.0.21") {
|
||||||
|
t.Errorf("command %s", cmd)
|
||||||
|
}
|
||||||
|
// None again: the judge machine is no longer asked for.
|
||||||
|
m = press(m, "left").(setupModel)
|
||||||
|
if m.judges != 0 || strings.Contains(m.machinesView(), "Judge machine 1") {
|
||||||
|
t.Errorf("judges %d after choosing none", m.judges)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTheMemoryAMachineNeedsCountsWhatItServes(t *testing.T) {
|
||||||
|
in := &serverInit{}
|
||||||
|
mb, what := in.servingMemoryMB(false)
|
||||||
|
need := preflight.Needs[preflight.RoleControl].MemoryMB + mb
|
||||||
|
if what == "" || need <= 7941 || need > 11961 {
|
||||||
|
t.Errorf("node, starter and judges here: need %d MB (%q); an 8 GB machine must fail and a 12 GB one pass", need, what)
|
||||||
|
}
|
||||||
|
// Judges on machines of their own take their share off this one.
|
||||||
|
if mb2, _ := in.servingMemoryMB(true); mb2 >= mb {
|
||||||
|
t.Errorf("with judge machines %d MB, without %d", mb2, mb)
|
||||||
|
}
|
||||||
|
// A control plane that serves nothing needs only its own.
|
||||||
|
if mb, what := (&serverInit{noNode: true}).servingMemoryMB(false); mb != 0 || what != "" {
|
||||||
|
t.Errorf("no node: %d %q", mb, what)
|
||||||
|
}
|
||||||
|
if mb, _ := (&serverInit{noModel: true, noGuardModel: true}).servingMemoryMB(false); mb != 0 {
|
||||||
|
t.Errorf("nothing served: %d", mb)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.yaml.in/yaml/v3"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Where the cluster says what went wrong: a model parked, a node lost, a
|
||||||
|
// budget spent. notify.webhooks existed, and setup never asked for one, so a
|
||||||
|
// site learnt of a lost node from its users.
|
||||||
|
|
||||||
|
// parseWebhook reads a webhook as setup takes it: an address, with "slack:"
|
||||||
|
// or "json:" in front to say what it is sent, else what its address says.
|
||||||
|
func parseWebhook(s string) (notify.Webhook, error) {
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
format := ""
|
||||||
|
for _, f := range []string{"slack", "json"} {
|
||||||
|
if rest, ok := strings.CutPrefix(s, f+":"); ok && !strings.HasPrefix(rest, "//") {
|
||||||
|
format, s = f, rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
u, err := url.Parse(s)
|
||||||
|
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||||
|
return notify.Webhook{}, fmt.Errorf("webhook %q: an http(s) address, with slack: or json: in front to say its format", s)
|
||||||
|
}
|
||||||
|
if format == "" {
|
||||||
|
format = webhookFormat(u)
|
||||||
|
}
|
||||||
|
return notify.Webhook{URL: s, Format: format}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookFormat is what an address takes: Slack's own, and the incoming
|
||||||
|
// webhooks that read Slack's {"text": …} — Mattermost's and Rocket.Chat's
|
||||||
|
// /hooks/, Discord's …/slack — are sent that; anything else the event as
|
||||||
|
// JSON.
|
||||||
|
func webhookFormat(u *url.URL) string {
|
||||||
|
switch {
|
||||||
|
case u.Host == "hooks.slack.com",
|
||||||
|
strings.Contains(u.Path, "/hooks/"),
|
||||||
|
strings.HasSuffix(u.Path, "/slack"):
|
||||||
|
return "slack"
|
||||||
|
}
|
||||||
|
return "json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWebhooks reads a list of them, by commas or as given.
|
||||||
|
func parseWebhooks(list []string) ([]notify.Webhook, error) {
|
||||||
|
var out []notify.Webhook
|
||||||
|
for _, item := range list {
|
||||||
|
for _, s := range strings.Split(item, ",") {
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
w, err := parseWebhook(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// webhookEntry is one of notify.webhooks as the settings document has it.
|
||||||
|
type webhookEntry struct {
|
||||||
|
URL string `yaml:"url"`
|
||||||
|
Format string `yaml:"format,omitempty"`
|
||||||
|
Events []string `yaml:"events,omitempty,flow"`
|
||||||
|
SecretFile string `yaml:"secret_file,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// withWebhooks adds these webhooks to a settings document's notify.webhooks,
|
||||||
|
// and says whether that changed it: one already there, by address, is left
|
||||||
|
// as it is, with whatever events and secret it was given.
|
||||||
|
func withWebhooks(doc *settings.Doc, add []notify.Webhook) (bool, error) {
|
||||||
|
path := []string{"notify", "webhooks"}
|
||||||
|
var list []webhookEntry
|
||||||
|
if n := doc.Node(path); n != nil {
|
||||||
|
if err := n.Decode(&list); err != nil {
|
||||||
|
return false, fmt.Errorf("notify.webhooks in the cluster's settings: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for _, w := range add {
|
||||||
|
if !slices.ContainsFunc(list, func(e webhookEntry) bool { return e.URL == w.URL }) {
|
||||||
|
list = append(list, webhookEntry{URL: w.URL, Format: w.Format})
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
b, err := yaml.Marshal(list)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return true, doc.SetYAML(path, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureWebhooks puts the webhooks given into the cluster's settings and
|
||||||
|
// sends each a test, once the control plane runs on them. Said, not fatal: a
|
||||||
|
// cluster without its alerts still serves.
|
||||||
|
func (in *serverInit) ensureWebhooks(ctx context.Context) error {
|
||||||
|
if len(in.webhooks) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
hooks, err := parseWebhooks(in.webhooks)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c := in.adminAPI()
|
||||||
|
var cur adminapi.SettingsView
|
||||||
|
if err := c.get(ctx, "/api/v1/settings", &cur); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
doc, err := settings.FromBytes("settings", []byte(cur.Body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed, err := withWebhooks(doc, hooks)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
b, err := doc.Bytes()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, _ := json.Marshal(adminapi.SettingsRequest{Base: cur.Version, Body: string(b), Note: "notification webhooks, from setup"})
|
||||||
|
if err := c.put(ctx, "/api/v1/settings", req, &cur); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.done("notification webhooks in the cluster's settings (version %d)", cur.Version)
|
||||||
|
} else {
|
||||||
|
in.kept("notification webhooks (already in the cluster's settings)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The control planes start again on the change; the test is sent by
|
||||||
|
// one that knows every webhook asked for.
|
||||||
|
var res []notify.Result
|
||||||
|
err = waitFor(ctx, 2*time.Minute, "the control plane to run on its webhooks", func() (bool, error) {
|
||||||
|
res = nil
|
||||||
|
if c.post(ctx, "/api/v1/notify/test", nil, &res) != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
for _, w := range hooks {
|
||||||
|
// Answered by host: the path of a webhook is its secret.
|
||||||
|
if !slices.ContainsFunc(res, func(r notify.Result) bool { return r.URL == notify.Redact(w.URL) }) {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var failed []string
|
||||||
|
for _, r := range res {
|
||||||
|
if !slices.ContainsFunc(hooks, func(w notify.Webhook) bool { return notify.Redact(w.URL) == r.URL }) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if r.Err != "" {
|
||||||
|
failed = append(failed, r.URL+": "+r.Err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
in.done("a test notification reached %s", r.URL)
|
||||||
|
}
|
||||||
|
if len(failed) > 0 {
|
||||||
|
return errors.New("a test notification did not arrive: " + strings.Join(failed, "; ") +
|
||||||
|
" (kept in the settings: 'llmgw ctl notify test' tries again)")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
Loaded 100 of 492 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user