Compare commits
140
Commits
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 ./...
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
# Fails when an engine llmgw follows release by release (FreeToken) has
|
||||||
|
# published a version newer than the one DefaultEngines installs: the failed
|
||||||
|
# run is the notice to check the new version and move to it. See
|
||||||
|
# scripts/engine_releases.py.
|
||||||
|
name: Engine releases
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
# Mondays 06:00 UTC.
|
||||||
|
- cron: "0 6 * * 1"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Compare with PyPI
|
||||||
|
run: python3 scripts/engine_releases.py
|
||||||
@@ -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/
|
||||||
+561
-8
@@ -15,6 +15,65 @@ 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**~~: done 2026-10-29. The done-criterion held on the PoC: with mirror 146 stopped, `ctl model serve` of a
|
||||||
|
new model was fetched by the second mirror (VM 167, 10.0.0.152), placed and answered; a node without a file
|
||||||
|
146's address names was given 152's; what was added meanwhile reached 146 when it came back; a removal was
|
||||||
|
carried both ways. Found and fixed there: add-peer on a mirror machine of its own, files without checksums never
|
||||||
|
copied, and `mirror remove` neither recorded nor seen by the running mirror.
|
||||||
|
6. **F5**: large imports. Done 2026-10-29: the gateway embeds what `llmgw-kb` sends as text; the slots and
|
||||||
|
halfvec were done before. Left: a larger store machine before a million chunks (the operator's), and the
|
||||||
|
embedding model on a GPU for a real import.
|
||||||
|
|
||||||
|
**From Kong** (section L): L1–L5 and L9 done (v0.15.0); left of L4 is routing by cost between outside
|
||||||
|
providers, of L5 cache-write and audio prices. L6 and L7 are llmgw-80's; L8 (A2A) when asked.
|
||||||
|
|
||||||
|
**Models we can trust** (section M, 2026-10-25): M1 done (pickles refused; the explorer lists only what this
|
||||||
|
cluster's hardware holds, with CPU and GPU tok/s). Next M2 (every download pinned and checked against Hugging
|
||||||
|
Face, done 2026-10-30), then M5 (engines without the internet), M3, M4, M6.
|
||||||
|
|
||||||
|
**Scale.** O: more than one control plane answering clients; O1 (measuring one router's ceiling) first.
|
||||||
|
|
||||||
|
**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**~~: done 2026-10-25 (in memory, not pgvector); left: the 40-rewordings run on the PoC.
|
||||||
|
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 runs a dev build of main (v0.15.0-7-ga583359) on its control planes and nodes, the mirror v0.15.0.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## A. Security — do first
|
## A. Security — do first
|
||||||
@@ -1073,13 +1132,19 @@ 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
|
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
|
queue and 0.04 s in the guard. The GPU sat at 18–50% and the store machine
|
||||||
at a third of its CPUs.
|
at a third of its CPUs.
|
||||||
- An embedding model's profile with many slots by default (`--parallel`
|
- Done for `ctl model serve` (32 slots of 1024 tokens; `ctl model context
|
||||||
|
MODEL 1024 --slots 32` resizes one served before): an embedding model's profile with many slots by default (`--parallel`
|
||||||
32–64, `--ctx-size` to match, `--batch-size`/`--ubatch-size` at least the
|
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
|
longest chunk), or `ctl model serve` choosing that for a model with
|
||||||
`capabilities.embeddings`. With the default 512, one 516-token chunk failed.
|
`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
|
- Done (2026-10-29): embedding on the gateway's side of a document PUT.
|
||||||
text only: today an embeddings answer is ~21 KB of JSON a chunk, and
|
`llmgw-kb` sends text only to a gateway that says `server_embeds`; the
|
||||||
the text and vector go up again (~11 KB).
|
gateway embeds through its own `/v1/embeddings` as the caller, 32 a batch,
|
||||||
|
four batches at once, waiting out a busy engine. On 40 DTIC files the
|
||||||
|
upload went from 25.7 to 3.5 MiB, and the ~21 KB a chunk that came down
|
||||||
|
is gone; on 6 files both ways gave the same 32 chunks and the same search
|
||||||
|
answer. Speed is the engine's either way: the PoC's bge-m3 on node02's two
|
||||||
|
CPU cores embeds ~0.2 chunks/s, so a real import still wants it on a GPU.
|
||||||
- The store next: HNSW inserts took half of one of pg01's two cores; at a
|
- 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 ≥
|
million chunks the vectors alone outgrow its 4 GB. halfvec (pgvector ≥
|
||||||
0.7) and a larger store machine.
|
0.7) and a larger store machine.
|
||||||
@@ -1426,13 +1491,501 @@ console show and add them.
|
|||||||
`on_demand.evict_after_idle` / `min_lifetime` as live settings; an
|
`on_demand.evict_after_idle` / `min_lifetime` as live settings; an
|
||||||
upgrade that leaves nodes behind says which; `ctl model serve` serves 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.
|
embedding model for embedding; refreshes wait while a form is filled in.
|
||||||
- Open: llmgw's `/mcp` with picture, 3D, VLM and workflow tools, so a
|
- Done 2026-10-15: llmgw's `/mcp` with picture, 3D, VLM and workflow tools,
|
||||||
client needs no server of its own (asked by the game team); the shared
|
so a client needs no server of its own (asked by the game team); the
|
||||||
ComfyUI replica (H3's placement side).
|
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) — done 2026-10-29, tried on the PoC
|
||||||
|
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.
|
||||||
|
With `router.active_active` (O2, 2026-11-01) the counts are shared every 2 s,
|
||||||
|
so a standby taking over keeps the minute; without it, as above.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
## M. Models from Hugging Face we can trust (written 2026-10-25)
|
||||||
|
|
||||||
|
The user asked how we know a model fetched from Hugging Face is safe. Two
|
||||||
|
questions: whether its files can run code on our machines, which we can
|
||||||
|
close, and whether the model itself was trained to misbehave, which no scan
|
||||||
|
proves — only who published it, an eval before it serves, and the
|
||||||
|
guardrails narrow that. What existed: `--trust-remote-code` refused, no
|
||||||
|
repository Python fetched, ComfyUI nodes pinned to a commit, engines
|
||||||
|
hash-pinned and run as `llmgw-engine`, mirror versions pinned to a commit
|
||||||
|
and never overwritten. What did not: everything below.
|
||||||
|
|
||||||
|
### M1. Weights in a pickle format refused (S) — done 2026-10-25
|
||||||
|
`.bin`, `.pt`, `.pth`, `.ckpt`, `.pkl` run code as they are loaded, which is
|
||||||
|
how the malicious models found on Hugging Face work. The node refuses such a
|
||||||
|
source, or a mirror set holding one, before fetching anything; a model being
|
||||||
|
added or given such a source is refused; vLLM and SGLang downloading a model
|
||||||
|
by its id are given `--load-format safetensors`. The explorer hides a repository with neither GGUF nor safetensors
|
||||||
|
(under "runs here", ticked by default) and does not offer a repository's
|
||||||
|
pickle files. `allow_pickle: true` on a model lets it through, on purpose. On the
|
||||||
|
PoC, `triposr` (`hf:stabilityai/TripoSR/model.ckpt`) is the one model that
|
||||||
|
needs it.
|
||||||
|
|
||||||
|
### M2. Every download pinned and checked against Hugging Face (M) — done 2026-10-30
|
||||||
|
Done: what is added from Hugging Face is pinned (the file's commit URL, or
|
||||||
|
`--revision` for vLLM/SGLang) with each file's sha256 in the origin; the node
|
||||||
|
refuses and deletes a copy that is not it, naming both, and keeps the
|
||||||
|
checksum beside a cached file; the mirror checks what `ctl model serve`
|
||||||
|
fetches, `mirror add` a Hugging Face URL. Left: models declared in the
|
||||||
|
settings are not pinned, the admin page's and the console's mirror fetches
|
||||||
|
send no sha256, and adapters by hub id are not pinned.
|
||||||
|
- `hf:org/repo/file` follows `main` and is checked by nothing; a bare
|
||||||
|
`org/repo` given to vLLM is downloaded by vLLM, of whatever `main` is.
|
||||||
|
Both: resolve to the commit when the model is added, keep it in the
|
||||||
|
catalog's origin, and fetch that commit only (`--revision` for vLLM).
|
||||||
|
- The sha256 Hugging Face publishes for each LFS file compared on every
|
||||||
|
path, and a file that differs removed: `mirror add`, the manager, the
|
||||||
|
node's `hf:` and http sources, mirror set files with no sha recorded.
|
||||||
|
- Done when: a file changed on Hugging Face after a model was added is
|
||||||
|
refused by the node with both checksums named.
|
||||||
|
|
||||||
|
### M3. Hugging Face's own scan (S)
|
||||||
|
Hugging Face scans every file (pickle imports, ClamAV, partners' scanners)
|
||||||
|
and marks a file unsafe. Ask for it with the repository listing; refuse an
|
||||||
|
unsafe file, show the result on the mirror page and in the catalog's origin.
|
||||||
|
The API field has to be confirmed first.
|
||||||
|
|
||||||
|
### M4. Publishers we trust, and the rest asked about (S)
|
||||||
|
An allow-list of organisations in the cluster's settings (Qwen, meta-llama,
|
||||||
|
mistralai, google, microsoft, …). A model from anyone else is added only by
|
||||||
|
an admin who says so, and who said it is kept in the origin. The PoC already
|
||||||
|
serves four community models whose training nobody here checked.
|
||||||
|
|
||||||
|
### M5. Engines without the internet (M)
|
||||||
|
The firewall is inbound only, so an engine that was made to run code can
|
||||||
|
send what it reads anywhere. With weights from the mirror the engines need
|
||||||
|
no egress: an nft output rule for the engine user (allow the mirror, the
|
||||||
|
cluster, loopback), systemd hardening of the agent's engines
|
||||||
|
(`NoNewPrivileges`, `ProtectSystem`, private /tmp). Agent itself still root.
|
||||||
|
|
||||||
|
### M6. An eval before a new model serves (S)
|
||||||
|
`eval` and `canary` exist; make a new model from an untrusted publisher
|
||||||
|
(M4) serve only after its eval suite passes, so a model trained to answer
|
||||||
|
wrongly on a trigger has at least been asked the suite.
|
||||||
|
|
||||||
|
## N. Strata, all the way up (written 2026-10-30)
|
||||||
|
|
||||||
|
Strata (opt-in engine since 2026-10-29) is wired at the agent: adapter, `llmgw strata-serve`, install pinned by
|
||||||
|
digest, metrics in vLLM's names, GPU memory of child processes. Above the agent it is invisible: a Strata model is
|
||||||
|
served only from a definition written by hand. In order:
|
||||||
|
|
||||||
|
**A. What is broken.**
|
||||||
|
- N1. A Strata named by hand (`engines.strata.dir`) was never reported: no Probe. Done 2026-10-30.
|
||||||
|
- N2. Its engine runs in a session of its own and survived a stop, holding the card and the RAM: the supervisor
|
||||||
|
now finds what left the group before signalling it. Done 2026-10-30.
|
||||||
|
- N3. Host memory: the experts live in RAM (40 GB for Qwen3.8-Flash-Next IQ2_XS, 72–136 GB for Unsloth's) and
|
||||||
|
nothing reserved it. A Strata profile added now reserves what its size takes, from the table (N5), unless it says
|
||||||
|
`min_memory_gb`. Done 2026-10-30.
|
||||||
|
- N4. Pascal and Volta: installed (CUDA 12.9 build) and then refused (7.0 floor). Floor now 6.0. Done 2026-10-30.
|
||||||
|
|
||||||
|
**B. Offered where models are chosen.**
|
||||||
|
- N5. A table of Strata's families and sizes (qwen, coder, swift, unsloth, gyro, nvfp4, qwen36, deepseek41) with
|
||||||
|
download and RAM, from Strata 0.2.7's setup (internal/strata/models.go); the catalog refuses an unknown family or
|
||||||
|
size, and requires `--size`. Done 2026-10-30. Speeds measured: Qwen3.8-Flash-Next IQ2_XS 70–87 tok/s on an RTX 6000.
|
||||||
|
- N6. `modelplan`, `ctl model plan` and `ctl model serve` offer "Strata on one card + N GB RAM" for a model it runs;
|
||||||
|
a large MoE that fits no card is not "fits nowhere". Done 2026-10-30, without a speed (one measurement so far).
|
||||||
|
- N7. The web serve page, the explorer's fit, the console's serve form: the same choice. Done 2026-10-30.
|
||||||
|
- N8. `--context`/`--parallel` tied to `context_length`/`max_output`; `ctl model context` takes Strata.
|
||||||
|
`ctl model context` takes Strata (setup's `--context`), and `--max-output` changes the longest answer alone,
|
||||||
|
in place, with no restart: done 2026-11-01 (31f440a). Open: a Strata model served without `--context` still runs
|
||||||
|
at what Strata's setup picks (4096 on cuda01) while the catalog says the model's trained context (32768), so
|
||||||
|
serve should write `--context` from `context_length`; and `--parallel`.
|
||||||
|
|
||||||
|
- N7b. The mirror page's Strata tab: every family and size, fetch (GGUF) or serve (packs Strata fetches itself:
|
||||||
|
NVFP4, DeepSeek V4.1). Done 2026-10-30. Community `strata-ple` packs Strata 0.2.7 does not list stay unservable;
|
||||||
|
running them would mean starting the engine past Strata's setup (`--pack`, `--native`, `--ple-gguf`).
|
||||||
|
|
||||||
|
**C. Operating it.**
|
||||||
|
- N9. `ctl engine-check strata`: the flags of setup.py and server.py, which drift between versions.
|
||||||
|
- N10. `ctl engine-build NODE strata cuda12|cuda13`, as vLLM has.
|
||||||
|
- N11a. Strata's per-replica state directories (engine-home/strata/<replica>) are left behind when a replica stops;
|
||||||
|
remove them with it.
|
||||||
|
- N11. Disk for the prepared pack and draft layer (and DeepSeek V4.1's 240–280 GB), cleaned with the weights; a
|
||||||
|
first start's preparation within the startup timeout.
|
||||||
|
- N12. docs/install.md: Strata in the engines table, a section with a profile, its memory and first-run time;
|
||||||
|
`doctor` and `server init` checking or offering it.
|
||||||
|
|
||||||
|
**D. What Strata can do that llmgw does not ask of it yet.**
|
||||||
|
- N13. Several cards of one node (`--gpus`, `--gpu-roles`): larger packs on a customer's multi-GPU machines.
|
||||||
|
- N14. `--calibrate` once per node and model, kept.
|
||||||
|
- N15. Vision, KV types, experts from SSD when RAM is short (`--low-ram`) in the plan and forms.
|
||||||
|
- N16. Versions apart from llmgw's releases (Strata's signed update feed, `--rollback-engine`); offline bundles.
|
||||||
|
- N17. `cache_salt` for KB isolation if Strata caches prefixes; `ctl bench` with its metrics path.
|
||||||
|
|
||||||
|
## O. More than one place answering clients (written 2026-10-30)
|
||||||
|
|
||||||
|
Every client request goes through the leader's router today: the floating address is on the leader, and a standby
|
||||||
|
passes `/v1` on to it (a standby's router sees an empty cluster). That is failover, not scale: the cluster serves what
|
||||||
|
one control plane's router can. In order:
|
||||||
|
|
||||||
|
- O1. Measured 2026-10-30/31 on the PoC: the leader (2 vCPU, 2 GB, pve1 at load 6–10 of 28 threads) in front of
|
||||||
|
a fake engine on node06 (`sim-engine`, 500 tok/s a stream, no batch slowdown, as an external model), 64-token
|
||||||
|
streams from mirror 146, the default `observe` guardrails and usage written to PostgreSQL:
|
||||||
|
|
||||||
|
| at once | req/s | tok/s | first token p50/p95 | leader llmgw CPU |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 64 | 465 | 29,752 | 9 / 34 ms | 159% |
|
||||||
|
| 128 | 572 | 36,607 | 91 / 167 ms | 181% |
|
||||||
|
| 256 | 577 | 36,900 | 310 / 392 ms | 182% |
|
||||||
|
| 512 | 551 | 35,240 | 793 / 883 ms | 181% |
|
||||||
|
|
||||||
|
The ceiling is the router's CPU: ~575 requests/s, ~37k streamed tokens/s on two cores, no errors; past it the
|
||||||
|
first token waits. Found on the way and fixed: an external model held to 3–4 at once by admission; a valid key
|
||||||
|
throttled as "unrecognised" when its cache entry expired under load.
|
||||||
|
|
||||||
|
Profiled under 128 streams (2026-10-31): a third of the CPU was one write a token to the client; joining the
|
||||||
|
events already in hand into one write took the ceiling to ~700 req/s and ~45k tok/s, first token p50 45 ms at 128.
|
||||||
|
Now the largest part is the output guardrail of the default `observe` policy, ~30%: decoding each event (~20%;
|
||||||
|
a token-level reader with encoding/json/jsontext was measured slower than json.Unmarshal, 1.66 vs 1.27 µs) and
|
||||||
|
the PII patterns (~12%). Ways on, a guardrail decision: judge shadow output in larger windows, or a sample of it.
|
||||||
|
Next: the same run on a machine the size of a customer's control plane, then O2.
|
||||||
|
- O2. Every control plane answers `/v1` itself (active-active):
|
||||||
|
- [x] the leader's replica table followed live by the standbys, so their routers route (2026-11-01, behind
|
||||||
|
`router.active_active`, off by default). Every 2 s a standby posts what its router used to the leader's
|
||||||
|
`POST /api/v1/routing/activity` over the agent port with its control-plane certificate, and is answered with
|
||||||
|
the Ready replicas: no keys travel, each control plane derives a replica's key from the cluster secret. The
|
||||||
|
leader takes the last uses in (else a replica busy behind a standby is evicted as idle) and the on-demand models
|
||||||
|
asked for; a catalog changed on the leader is read again from the store. Unheard from the leader for a minute,
|
||||||
|
a standby routes nothing and passes `/v1` on as before. The portal, `/admin` and `/mcp` still go to the leader.
|
||||||
|
- [x] budgets: each router raises its counters to the store's every 30 s, so another's spend is seen within that.
|
||||||
|
- [x] rate limits cluster-wide (2026-11-01): each router's per-counter request and token counts, this minute's
|
||||||
|
and the last as absolute figures, ride the same 2 s exchange; the leader answers each standby with everyone
|
||||||
|
else's sum, its own included, and puts the standbys' sum in force on its own router. Each limiter adds the
|
||||||
|
others' counts to its sliding window. Overshoot is bounded by what N routers allow in 2 s; a standby quiet for
|
||||||
|
10 s stops counting. (J6's single-process case was never the problem.)
|
||||||
|
- [ ] a session's requests to one router and one replica: the session ring already picks the same replica on
|
||||||
|
every router for the same replica set; what is left is the same router, which is the balancer's (source hash).
|
||||||
|
- [ ] in front: IPVS on the keepalived the setup already installs, DNS round-robin, or the customer's balancer
|
||||||
|
(J7, L9). Until then the standbys' own addresses are what spread the load.
|
||||||
|
- [x] tried on the PoC (2026-11-01, v0.16.1-14-g8b7eefe, setting on since): 140 leads, 150 follows. A request
|
||||||
|
through 150 is answered and logged by 150; a model added on the leader is routable on 150 within a tick; a
|
||||||
|
CPU replica used only through 150 shows idle 6 s on the leader. Load from 146, 64-token streams, a sim engine on
|
||||||
|
node06 at 5,000 tok/s a stream (2,300 req/s on its own; at 500 tok/s it was the ceiling, ~490 req/s, routers or
|
||||||
|
not), pve1 at load 14 of 28 threads:
|
||||||
|
|
||||||
|
| streams | leader alone | split over 140 + 150 | first token p50, alone / split |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 128 | 971 req/s (140 at 176%) | 832 + 692 = 1,524 req/s (178% / 166%) | 115 / 59–75 ms |
|
||||||
|
| 256 | 990 req/s (177%) | 816 + 677 = 1,493 req/s (177% / 164%) | 243 / 135–166 ms |
|
||||||
|
|
||||||
|
1.5× with two routers on an oversubscribed host, no errors. Not 2×: both routers' VMs and the load generator
|
||||||
|
share pve1's cores.
|
||||||
|
- Known and accepted for now, per router: the precise KV index is empty on a standby (agents stream to the leader
|
||||||
|
only), a revoked key works on a standby until `auth.cache_ttl`, canary promotion sees the leader's share only,
|
||||||
|
the answer cache and prefix tree are split, an MCP caller's rate is per router.
|
||||||
|
- O3. Only if O2 is not enough: stateless gateway machines (`llmgw server --role gateway`) in front of the leader's
|
||||||
|
control plane, as many as the traffic needs.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
+895
-2
@@ -18,16 +18,864 @@ 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.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.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves; v0.14.6: setup that explains every part and lets it be chosen, the gateway on all three HA machines; v0.14.7: the judge sized for its machine; v0.14.8: sign-in with Active Directory, several keys for one outside provider; v0.14.9: sign-in set up from the console, a node's console that shows the machine) |
|
||||||
|
| v0.15.0 | — | The company's MCP tools and VS Code through the gateway; guardrails that ask outside services and put redacted values back; answers to similar questions | released (v0.15.1: Strata, a 125B model of experts on one card; a second mirror kept in step; pickled weights refused; a served model's context changed; v0.15.2: a served model moved to more cards; v0.15.3: a model stopped and kept, or removed with its files) |
|
||||||
|
| v0.16.0 | — | Weights pinned to a commit and checked against what Hugging Face published; documents embedded by the gateway; Strata offered where models are chosen; cluster upgrades that say how far they are | released (v0.16.1: the mirror's Strata tab, Strata's ready-made packs; v0.16.2: standbys that answer clients, FreeToken, a model fetched once per node; v0.16.3: replicas per profile, a served model's settings on the mirror page, the CPU's speed before serving) |
|
||||||
| v1.0.0 | 5 | What phase 5 has left: precise KV routing proven on vLLM, the block cost picker, defragmentation across NVLink domains, prefill/decode disaggregation | planned |
|
| 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.16.3 — 2026-11-03
|
||||||
|
|
||||||
|
Replicas counted per profile — seven on cards and four on CPUs under one name, the cards filled first — and every
|
||||||
|
setting of a served model from the mirror page; how fast a model answers on the CPU, said before it is served, by
|
||||||
|
the threads it is given rather than the whole machine's memory.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- No migrations. `llmgw ctl upgrade v0.16.3`: the control planes carry the replicas per profile, the model card and
|
||||||
|
the speed in the serve form and in `ctl model plan`; the agents carry the measurement of one core's memory rate,
|
||||||
|
without which the speed is reckoned the old way, from the whole machine's.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Replicas counted per profile: seven on cards and four on CPUs, under one
|
||||||
|
name.** `llmgw ctl model replicas <model> gpu=7 cpu=4`, the console's
|
||||||
|
Replicas per profile… and the mirror page's model card set how many of a
|
||||||
|
model's replicas run on each of its profiles, rather than every replica
|
||||||
|
on the best profile that fits. For a GGUF model with no profile on CPUs
|
||||||
|
one is added (`--cpu-memory`), preferred below the cards, so the router
|
||||||
|
fills the cards first and sends to the CPUs once they are full. A change
|
||||||
|
starts the replicas short before stopping those over: the model never
|
||||||
|
runs fewer. `ctl models` shows the counts; a scaling of another total
|
||||||
|
ends them.
|
||||||
|
- **Every setting of a served model from the mirror page.** A served
|
||||||
|
model's row has a Settings link, and its card now changes the context
|
||||||
|
and longest answer, the cards a replica takes, the replicas on each
|
||||||
|
profile (with the CPU's speed beside), stops and starts it, restarts its
|
||||||
|
replicas, and takes it out of the catalog with its files or without —
|
||||||
|
beside the replicas, class, owner, memory and adapters it held before.
|
||||||
|
Stopped on the row is now the model stopped and kept, started again as
|
||||||
|
it was, rather than its scaling set to nothing.
|
||||||
|
- **How fast a model answers on the CPU, before it is served.** The serve
|
||||||
|
form says, as the cores are changed, about how many tokens a second one
|
||||||
|
reply gets on the CPU and on one card of the fleet's fastest class, and
|
||||||
|
warns when it is slow (under 15) or very slow (under 5, a reply of 500
|
||||||
|
tokens taking minutes). Very slow on the CPU is refused until "run on the
|
||||||
|
CPU anyway" is ticked, GPUs free or not. `llmgw ctl model plan` and the
|
||||||
|
console's Hugging Face screen show the same figure for every way a model
|
||||||
|
can run, and `ctl model serve --cpu` says it in its log.
|
||||||
|
- **The speed follows the threads a replica is given.** Each agent now
|
||||||
|
measures what one core reads beside what all of them read: a replica of
|
||||||
|
four threads on a large server reads at about four cores' rate, not the
|
||||||
|
server's, which the estimate used to assume and overstated several times.
|
||||||
|
Calibrated on the PoC by llama-bench at 1, 2, 4 and 8 threads (within 15%),
|
||||||
|
and a model of experts reads its experts' share, from the GGUF header.
|
||||||
|
Agents need upgrading to report one core's rate; until then the old
|
||||||
|
estimate is used.
|
||||||
|
|
||||||
|
## v0.16.2 — 2026-11-02
|
||||||
|
|
||||||
|
Standby control planes that answer clients themselves, with rate limits and budgets kept across them; FreeToken, an
|
||||||
|
engine for models larger than the card; a served model's longest answer changed in place; and fixes found putting
|
||||||
|
the router under load and fetching large models: a multi-file model fetched once per node however many replicas
|
||||||
|
start there, downloads that carry on where they were cut off, and a router that does twice the work on two cores.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- No migrations. `llmgw ctl upgrade v0.16.2`: the control planes carry active-active routing (off until
|
||||||
|
`llmgw ctl config set router.active_active true`) and the router fixes; the agents carry the set fetched once
|
||||||
|
per node and the set's progress and checking; the mirror carries the resumed downloads.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Standbys that answer the client API themselves.** With
|
||||||
|
`router.active_active: true` in the cluster's settings, a standby control
|
||||||
|
plane routes `/v1` to the leader's replicas instead of passing every
|
||||||
|
request to the leader, so more machines carry the traffic. It hears the
|
||||||
|
replicas from the leader every two seconds over the agent port (no engine
|
||||||
|
keys travel) and reports back what it used, so the leader does not evict
|
||||||
|
a replica busy behind a standby, and the on-demand models it was asked
|
||||||
|
for. Rate limits are the cluster's: each router's request and token
|
||||||
|
counts reach the others through the leader every two seconds, so a key
|
||||||
|
spread over several control planes gets its limit, not that many times
|
||||||
|
it. Budgets are brought up to the store's every 30 seconds. Off by
|
||||||
|
default; the portal, `/admin` and `/mcp` still go to the leader.
|
||||||
|
- **A served model's longest answer, changed in place.** `llmgw ctl model
|
||||||
|
context <model> --max-output N` (and `max_output` on `POST
|
||||||
|
/api/v1/models/{name}/context`, and the console's Context length… form)
|
||||||
|
sets the longest answer on its own: the catalog's limit, in force at once,
|
||||||
|
with no replica started again. With a context it is set with it. Strata's
|
||||||
|
context can now be changed too (its setup's `--context`), so neither
|
||||||
|
needs the model removed and added again.
|
||||||
|
- **FreeToken, an engine for models larger than the card.** Mixture-of-experts
|
||||||
|
models several times a card's size, from one card, with the experts in RAM
|
||||||
|
and streamed over PCIe — read from Hugging Face safetensors checkpoints as
|
||||||
|
published (DeepSeek V4 Flash, GLM-5.x, Qwen3.6/3.8, gpt-oss, Gemma 4,
|
||||||
|
MiniMax), with batching. Installed like vLLM on every node it runs on: a
|
||||||
|
card of compute capability 8.0 or newer, driver 580+, x86-64, and the CUDA 13
|
||||||
|
toolkit it compiles kernels with; older cards are passed over (tried on a
|
||||||
|
Turing card, where 0.2.1 has no kernels). Profiles are one card
|
||||||
|
(`engine: freetoken`); the key goes in its environment, and flags that read
|
||||||
|
files or fetch are refused. Readiness now also waits for an engine whose
|
||||||
|
`/health` answers 200 while saying `loading` in its body. FreeToken is
|
||||||
|
followed release by release: a weekly workflow fails when PyPI has a version
|
||||||
|
newer than `DefaultEngines` installs.
|
||||||
|
- **How much of each GPU works, beside whether anything does.** The busy
|
||||||
|
figure is nvidia-smi's `utilization.gpu` — the share of time any kernel
|
||||||
|
ran, which a kernel on one SM fills — and it is now said to be that: on
|
||||||
|
the dashboard, the node page, the metric's help and Grafana. Beside it,
|
||||||
|
each card's draw against its power limit (the efficiency table, the fleet
|
||||||
|
and node charts, the console's GPU work chart), and where the card's
|
||||||
|
profiling counters can be read, its SMs, SM occupancy, tensor cores and
|
||||||
|
memory interface: `llmgw_gpu_sm_active_percent`,
|
||||||
|
`llmgw_gpu_sm_occupancy_percent`, `llmgw_gpu_tensor_active_percent`,
|
||||||
|
`llmgw_gpu_dram_active_percent`. The agent reads them from NVML's GPM on
|
||||||
|
Hopper and later, and from a dcgm-exporter on the node elsewhere
|
||||||
|
(`discovery.dcgm_exporter`, default `http://127.0.0.1:9400/metrics`, `off`
|
||||||
|
for none). The GPU map colours by this "work" by default — SMs active
|
||||||
|
where read, power against the limit otherwise — and keeps busy as a mode.
|
||||||
|
On cuda01 decode read 92% busy with the SMs at work 69% of the time.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Several replicas of a multi-file model on one node fetched it once
|
||||||
|
each, over one another.** Four replicas placed on a node's four cards
|
||||||
|
wanted the same safetensors set at the same moment, each wrote the same
|
||||||
|
`.part` files, and all but one failed to start ("rename … no such file")
|
||||||
|
until a retry found the set finished. A set is now fetched once per node
|
||||||
|
and every replica waiting on it gets it, as single files always were.
|
||||||
|
- **A model of several files showed one file's download as the whole.** A node fetching a set reported the part it
|
||||||
|
was on — "59% of 46.1 GB" for a 188 GB model — instead of the set: it now reports the set's bytes against its
|
||||||
|
whole size.
|
||||||
|
- **A set's files checked with nothing said, one at a time.** After the last byte arrived a node checked each file
|
||||||
|
against the mirror's checksum, 195 GB on one core for eight minutes, while the replica still read "fetching 100%".
|
||||||
|
It now checks four at once and says so: `checking 40% of 195 GB` in `ctl replicas`, "Checking the weights" in the
|
||||||
|
chat page; and a check that takes long is not taken for a stuck download.
|
||||||
|
- **A mirror download the registry cut off started again from nothing, or not at all.** Hugging Face's CDN ended a
|
||||||
|
60 GB transfer at 23 GB (`stream error … CANCEL`), the mirror deleted what had arrived, and `ctl model serve`
|
||||||
|
failed. The mirror now tries again up to six times, carrying on from where it got to (`Range`), keeps what arrived
|
||||||
|
when it gives up, and carries on from it after a restart too.
|
||||||
|
- **A model from an outside provider took three or four requests at once.** Admission read every request in flight to
|
||||||
|
an endpoint it does not measure as a queue, so an external model (OpenAI, another llmgw cluster) was held to what a
|
||||||
|
saturation of 0.85 allows of "in flight / 4"; the rest waited and timed out with 504. An outside provider queues for
|
||||||
|
itself and says 429 when full, which its breaker already hears: it no longer counts towards saturation.
|
||||||
|
- **A valid key told to slow down under load, every thirty seconds.** When a busy key's cached entry expired, every
|
||||||
|
request in flight missed the cache together and each took one of the address's allowance for unknown keys: past a
|
||||||
|
hundred or so streams at once, a valid key got 429 "too many unrecognised api keys". Requests missing the same key
|
||||||
|
at once now share one read and one allowance.
|
||||||
|
- **A streamed answer was one write to the client a token.** Each event was flushed alone, a system call each: a
|
||||||
|
third of a busy router's CPU. Events already received from the engine now go out in one write, with no token
|
||||||
|
waiting for another; on the PoC's two-core leader the ceiling rose from ~575 to ~700 streams' requests a second,
|
||||||
|
and the first token at 128 at once from 91 to 45 ms.
|
||||||
|
|
||||||
|
## v0.16.1 — 2026-10-30
|
||||||
|
|
||||||
|
The mirror page's Strata tab, Strata's ready-made packs served with Strata fetching them, and two fixes found
|
||||||
|
serving Strata's models: repositories that keep each size in a directory, and DeepSeek on cards its engine does not
|
||||||
|
run on.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- No migrations. The control planes carry the tab, the plan and the serving; the agents carry the refusal of a
|
||||||
|
Strata profile its card cannot run (`llmgw ctl upgrade v0.16.1`). Mirrors need nothing new.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **The mirror page's Strata tab.** Every family and size Strata runs, with its download, the RAM it keeps and where
|
||||||
|
it would run here; a GGUF size fetched into the mirror in one press (checked against Hugging Face's checksums),
|
||||||
|
and the packs Strata fetches itself — NVFP4, DeepSeek V4.1 — served at once, their repository the source.
|
||||||
|
`ctl model plan` and `ctl model serve` take those repositories too, and the explorer names a `strata-ple`
|
||||||
|
repository for what it is: part of a pack Strata knows, or one Strata 0.2.7 does not.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A repository that keeps each quantisation in a directory of its own** (ISTA-DASLab's `IQ2_XS/`, `IQ3_S/`) listed
|
||||||
|
no weights: its file tree is now read recursively, by the explorer, the plan and `ctl model serve`.
|
||||||
|
- **DeepSeek V4.1 offered on a card its engine was not built for.** Strata's DeepSeek engine runs on RTX 30 (compute
|
||||||
|
8.6) and newer; on a Turing card the tab and the plan offered it, and Strata's setup refused it once placed. Now
|
||||||
|
neither offers it there, the tab says why, and the agent refuses such a profile before starting it.
|
||||||
|
|
||||||
|
## v0.16.0 — 2026-10-30
|
||||||
|
|
||||||
|
Weights pinned to the commit they were added at and checked, by the nodes and the mirror, against the sha256
|
||||||
|
Hugging Face published; the documents `llmgw-kb` sends embedded by the gateway; Strata offered by the plan, `ctl
|
||||||
|
model serve`, the explorer and the serve forms; and cluster upgrades that tell the nodes together, say how far they
|
||||||
|
are, and do not fail a node that came back on the version.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- No migrations. Upgrade the control planes, then the nodes (`llmgw ctl upgrade v0.16.0`), then each mirror's host
|
||||||
|
(`sudo llmgw upgrade v0.16.0 --service llmgw-mirror`).
|
||||||
|
- A node checks what it fetches against the catalog's checksums only once it runs this version; until then it
|
||||||
|
fetches as before.
|
||||||
|
- A model added from Hugging Face is now pinned when it is added, which needs the registry reachable from the
|
||||||
|
control plane; one it cannot be looked up in is refused, saying why. Models already in the catalog are left as
|
||||||
|
they are.
|
||||||
|
- A Strata profile must say `--size` as well as `--family`, and both must be ones Strata runs.
|
||||||
|
- `llmgw-kb` sends text alone to a gateway of this version; an older `llmgw-kb` keeps working, sending vectors.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Strata offered where models are chosen.** For a repository of a family Strata runs, `ctl model plan` offers "one
|
||||||
|
card with Strata, experts in RAM" with the RAM it takes, `ctl model serve --engine strata` (or the plan's choice)
|
||||||
|
builds its profile, the Hugging Face explorer lists it as running here, and the mirror's serve form serves such a
|
||||||
|
version on Strata. Strata's families and sizes are known: the catalog refuses one it does not run and reserves the
|
||||||
|
RAM a size keeps its experts in.
|
||||||
|
- **The gateway embeds the documents `llmgw-kb` sends.** A document may be sent as its passages' text alone; the
|
||||||
|
gateway embeds them through its own `/v1/embeddings` as the caller, 32 at a time and four batches at once,
|
||||||
|
waiting out a busy engine rather than failing the document. About a kilobyte a passage leaves the machine instead
|
||||||
|
of a vector down and up again, some thirty times more. `llmgw-kb` does so with a gateway that says it can
|
||||||
|
(`server_embeds` in a collection's answer); `--embed-here` keeps the old way.
|
||||||
|
- **Weights pinned to a commit, and checked against what Hugging Face published.** A model added from Hugging Face
|
||||||
|
(`hf:`, a resolve URL on a branch, or a repository vLLM or SGLang downloads) is pinned to the commit it is at
|
||||||
|
then — the file's URL at that commit, or `--revision` — and each file's published sha256 goes in its origin. A node
|
||||||
|
refuses and deletes a copy that is not it, naming both checksums; the mirror does the same for what `ctl model
|
||||||
|
serve` fetches, and `llmgw mirror add` for a Hugging Face URL (`--sha256` for others). Nodes need this version to
|
||||||
|
check; older ones fetch as before.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **A cluster upgrade that sat silent, or stopped on a node that had upgraded.**
|
||||||
|
- The nodes of a wave are told together, each saying when it is done, and what is still awaited is said every 30
|
||||||
|
seconds; before, each was told in turn, up to five minutes each, with nothing on the screen.
|
||||||
|
- The control plane takes the node registering again on the version as done: the agent's answer, sent a moment
|
||||||
|
before it exits, could be lost, and a node that had upgraded was failed as "older than v0.10.4".
|
||||||
|
- The agent's download no longer ends with its connection (a control plane restarting, leadership moving), and the
|
||||||
|
control plane waits longer than the download may take; a second request while one runs is refused.
|
||||||
|
- A cordoned or draining node upgraded is done; a node whose idle model stopped meanwhile is done once nothing on it
|
||||||
|
is starting; a node not connected is passed over and named at the end rather than stopping every node after it.
|
||||||
|
- The console's node upgrade waits as long as the control plane, and offers no development version.
|
||||||
|
- systemd never gives up restarting the agent (`StartLimitIntervalSec=0`), in units written from now on.
|
||||||
|
|
||||||
|
- **Strata.** One named by hand in `agent.yaml` was never reported, so never placed on; its engine, which leaves the
|
||||||
|
server's process group, is now stopped with it; Pascal and Volta, which its CUDA 12 build runs, were refused.
|
||||||
|
- **A name with a slash in it, asked through a standby control plane.** `llmgw ctl kb delete Demo/hr` on a standby
|
||||||
|
was answered 405: the standby passed the request on to the leader with `Demo%2Fhr` unescaped, two segments instead
|
||||||
|
of one. The path now reaches the leader as it was sent.
|
||||||
|
|
||||||
|
## v0.15.3 — 2026-10-29
|
||||||
|
|
||||||
|
A model stopped and kept, or removed with its files; the second mirror fixed where it was tried.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- No migrations. The control planes carry stop, start and purge; the nodes need nothing new.
|
||||||
|
- The mirrors carry the fixes: on each mirror's machine, `sudo llmgw upgrade v0.15.3 --service llmgw-mirror`.
|
||||||
|
A mirror with a peer then works out the checksums its older files lack, once, which reads every such file.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **A model stopped and kept, and a model removed with its files.** `llmgw ctl model stop|start MODEL`: none of its
|
||||||
|
replicas run, it stays in the catalog with its files, and start runs it again scaled as it was; requests are told an
|
||||||
|
administrator stopped it. `llmgw ctl model remove MODEL --purge` (`DELETE /api/v1/models/{name}?purge=true`) also
|
||||||
|
deletes every version of it on the mirror, a second mirror following, and the nodes' copies once its replicas have
|
||||||
|
stopped; what another model uses stays. Both are in the console's model menu.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **`llmgw mirror add-peer` on a mirror with a machine of its own.** It set the second mirror up and then
|
||||||
|
failed for want of the control plane's server.yaml; now it prints the `llmgw ctl config set mirror.peers`
|
||||||
|
to run on a control plane.
|
||||||
|
- **Models fetched before checksums were kept reach the second mirror.** A mirror kept in step copies only what it
|
||||||
|
can check, and those files had no sha256; a mirror with peers now works theirs out and records them, once.
|
||||||
|
- **A file removed with `llmgw mirror remove` is gone from the mirror's answers too.** The running mirror kept
|
||||||
|
reporting a file it had fetched as present for a week after it was removed, so a control plane could place a
|
||||||
|
model on a file that was not there; and the removal was not recorded, so a mirror kept in step copied it back.
|
||||||
|
|
||||||
|
## v0.15.2 — 2026-10-29
|
||||||
|
|
||||||
|
A served model moved to more cards, or fewer, while it is served.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- No migrations, and nothing changes until the command is used. The control
|
||||||
|
planes carry it; the nodes need nothing new.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **A served model moved to more cards, or fewer.** `llmgw ctl model gpus
|
||||||
|
MODEL N [--profile P] [--in-place]`, `POST /api/v1/models/{name}/gpus`, and
|
||||||
|
GPUs… in the console's model menu. How many cards of one node each replica
|
||||||
|
uses: tensor parallel for vLLM, SGLang and TensorRT-LLM, checked against
|
||||||
|
the model's KV heads, and the layers split for llama.cpp. Refused for a
|
||||||
|
count no node has, or whose cards would not hold the model and its
|
||||||
|
context, with the figures; its replicas are then started again on it, one
|
||||||
|
by one, beside the old — or, with `--in-place` when there is no room
|
||||||
|
beside them, each stopped first.
|
||||||
|
|
||||||
|
## v0.15.1 — 2026-10-29
|
||||||
|
|
||||||
|
A 125B model on one card, a second mirror, and weights that run code
|
||||||
|
refused. Strata runs as an engine: a mixture-of-experts model with its
|
||||||
|
experts in RAM, served from one 24 GB card. A second model mirror keeps in
|
||||||
|
step with the first, so an HA install has no single machine left. Pickled
|
||||||
|
weights are refused unless a model says otherwise. A served model's context
|
||||||
|
can be changed in place. The explorer says how fast a model would answer
|
||||||
|
here before it is fetched, and a card in the wrong PCIe slot is said so.
|
||||||
|
|
||||||
|
**Upgrading.**
|
||||||
|
- No migrations. Upgrade the control planes before the nodes, for the
|
||||||
|
pickle refusal below.
|
||||||
|
- A model whose weights are in a pickle format (`.bin`, `.pt`, `.pth`,
|
||||||
|
`.ckpt`, `.pkl`) stops being placed: its node refuses the source. Give it
|
||||||
|
its safetensors or GGUF, or add it again with `allow_pickle: true`
|
||||||
|
(`llmgw ctl model add -f`) if its files were checked. Look at the
|
||||||
|
models' sources before upgrading the nodes: one ending in
|
||||||
|
`.ckpt` or `.bin`, or a mirror version holding `pytorch_model.bin`. Upgrade the control planes first: a node on
|
||||||
|
this version refuses those weights until its control plane can say
|
||||||
|
`allow_pickle`.
|
||||||
|
- Strata is opt-in: nothing installs it until `engines` or an
|
||||||
|
`engine_overrides` entry names a version (`0.2.7`).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Strata as an engine: a 125B model of experts on one card.** Strata
|
||||||
|
(github.com/Niko1221/Strata) keeps a model's experts in RAM and the ones
|
||||||
|
used most in VRAM. On an RTX 6000 24 GB with 251 GB of RAM it serves
|
||||||
|
Qwen3.8-Flash-Next 125B (IQ2_XS) at 70–87 tokens a second to one client;
|
||||||
|
more clients share about that, its slots buying fairness rather than
|
||||||
|
throughput on one card. It runs a few families only — Qwen3.8-Flash-Next
|
||||||
|
and its variants, Qwen3.6-35B-A3B, DeepSeek V4.1-Flash — each named in a
|
||||||
|
profile's args with `--family` and `--size`
|
||||||
|
(`--family qwen --size IQ2_XS --context 32768`). The source is the
|
||||||
|
directory of the model's GGUF files, or the repository, which Strata then
|
||||||
|
downloads itself, pinned. The agent installs it on an NVIDIA node of
|
||||||
|
x86-64: its source pinned by digest, in a Python environment, and its
|
||||||
|
release's engine checked against the digest GitHub publishes — the CUDA 13
|
||||||
|
build for Turing and newer on driver 580, the CUDA 12.9 build for Pascal
|
||||||
|
to Ada, a V100 included, on driver 575. A replica prepares its model the
|
||||||
|
first time (minutes; its draft layer is a 5 GB download) and loads in
|
||||||
|
about a minute and a half after. One card of one node per replica, for
|
||||||
|
now; not in an offline bundle, its packages coming from PyPI.
|
||||||
|
|
||||||
|
- **A served model's context, changed.** `llmgw ctl model context MODEL
|
||||||
|
TOKENS [--slots N]`, `POST /api/v1/models/{name}/context`, and Context
|
||||||
|
length… in the console's model menu. The catalog, the engine's arguments and
|
||||||
|
the memory change together, checked against what the model was trained for
|
||||||
|
and what its cards hold; its replicas are then started again one by one. The
|
||||||
|
models API shows `context_length` and `max_output`.
|
||||||
|
|
||||||
|
- **A second model mirror, kept in step.** In a three-machine install the setup screens put one on a database machine
|
||||||
|
(`--second-mirror 1|2`). An install with one mirror gets one with `sudo llmgw mirror add-peer user@host`.
|
||||||
|
- **In step:** each mirror reads the other every thirty seconds. It copies what it lacks, checksum checked, and
|
||||||
|
removes what the other removed, even if it was down at the time (`llmgw mirror serve --peer`).
|
||||||
|
- **Control planes:** they name both (`mirror.peers`) and use whichever answers.
|
||||||
|
- **Nodes:** while one mirror is down, a node is given a model's file on the other.
|
||||||
|
|
||||||
|
This takes away the last single point of an HA install: with the first machine off, models are still added and
|
||||||
|
fetched.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **An engine that computes in a process of its own group was not its
|
||||||
|
replica's.** A GPU replica read as running on the CPU, and the memory it
|
||||||
|
held on its card as somebody else's, which placement then kept free of
|
||||||
|
other work. A process the replica started, at any remove, is now the
|
||||||
|
replica's. And what a replica computes on, once sent, was never sent
|
||||||
|
again: a wrong reading kept from before an agent's restart stayed wrong.
|
||||||
|
It is sent again when it changes, and read again when a GPU replica was
|
||||||
|
kept as the CPU.
|
||||||
|
|
||||||
|
- **VS Code's sign-in lost its place through the sign-in page**, so the
|
||||||
|
prompt to confirm came every time; a link to the same gateway by plain
|
||||||
|
http could take an https setting down to http; and a new build of the
|
||||||
|
extension was not installed over an old one (the command now says
|
||||||
|
`--force`).
|
||||||
|
- **The install guide's recordings were not checked for secrets**, only
|
||||||
|
the page; every frame is now.
|
||||||
|
- **MCP server fields could not be emptied**, an address could carry a
|
||||||
|
password, the cluster's own server could not be changed from the
|
||||||
|
console, and a grant to a key that does not exist was said done.
|
||||||
|
- **A guardrail service two policies named was asked as the first said**,
|
||||||
|
not the stricter; a `null` answer read as safe; and with `restore` the
|
||||||
|
request log kept the answer with the redacted values back in it.
|
||||||
|
- **What an engine downloads by a Hugging Face id was not held to the
|
||||||
|
pickle refusal** — an adapter by its id, a TensorRT-LLM model's; a model
|
||||||
|
with either now needs `allow_pickle`.
|
||||||
|
- **`server init` run again on a machine already set up was refused its
|
||||||
|
memory**, as on an 8 GB install fixing its certificate with `--host`, and
|
||||||
|
judges that would not be installed (no mirror) were counted.
|
||||||
|
- **Removing an MCP server left its sealed credential in the store**, and
|
||||||
|
replacing a credential under another header kept the old one sent. A
|
||||||
|
server's secrets now go when it no longer reads them.
|
||||||
|
- **A cloned repository could point the VS Code extension at another
|
||||||
|
gateway.** `llmgw.gatewayUrl` is a machine setting now, never taken from a
|
||||||
|
workspace.
|
||||||
|
- **A node's memory bandwidth could be measured in its cache** on a busy
|
||||||
|
node, and was 0 after a reconnect until the next heartbeat. The buffer is
|
||||||
|
at least eight times the processor's last cache.
|
||||||
|
- **A project kept in the cluster could have its questions embedded outside
|
||||||
|
it.** The similar-question cache now asks the embedding model as the
|
||||||
|
question's project would, external fallbacks refused, and skips a model
|
||||||
|
that is itself outside. Its comparing no longer holds the cache's lock,
|
||||||
|
which every project's lookups waited on.
|
||||||
|
- **Restored values were left as placeholders in streamed tool calls**, in
|
||||||
|
completions and in reasoning, and a value with a quote in a whole answer's
|
||||||
|
tool-call arguments broke their JSON.
|
||||||
|
- **An outside guardrail service read what `redact` had taken out**, and a
|
||||||
|
streamed answer was sent to it twice. The docs now say a service stops a
|
||||||
|
streamed answer only under `stream: buffer_all`.
|
||||||
|
- **A replica found slow once could stay unused.** Its time to first token
|
||||||
|
is forgotten after five minutes without an answer, so it is tried again.
|
||||||
|
- **Weights that run code got past the refusal in four ways.** A mirror
|
||||||
|
version already on a node was used without being looked at again; a
|
||||||
|
`?query` or `#fragment` on an `hf:` source hid the file's name; `.pt2` was
|
||||||
|
not on the list; and a profile could write `--load_format`,
|
||||||
|
`--trust_remote_code` or an abbreviation such as `--trust-remote`, which
|
||||||
|
vLLM and argparse read as the flag, or give SGLang `--load-format`. All
|
||||||
|
refused now. TripoSR's preset says `allow_pickle`, without which it could
|
||||||
|
not be added, and a shared ComfyUI environment takes its first member's.
|
||||||
|
- **A temperature written as a string was refused.** Since the GenAI traces
|
||||||
|
read it, `"temperature": "0.7"` got a 400 before reaching the engine; it
|
||||||
|
is read loosely again, for the trace only.
|
||||||
|
- **A reused answer could carry another caller's personal data.** With
|
||||||
|
`restore: true` and the answer cache on, two callers' requests that
|
||||||
|
redacted to the same text shared one answer — with the first caller's
|
||||||
|
address put back in it. Such requests are now never answered from the
|
||||||
|
cache nor kept in it.
|
||||||
|
- **Granting a server's tools could take others away; revoking could close
|
||||||
|
a level to every tool.** A level with no tool list sees what the levels
|
||||||
|
above grant; giving it its first list made it see only that list. And a
|
||||||
|
level left with an empty list was closed to every tool, which the screens
|
||||||
|
did not show. Now a first list starts from the nearest list above, taking
|
||||||
|
a server away leaves the rest or goes back to no list, the screens say
|
||||||
|
when a level above keeps granted tools out of reach and which levels are
|
||||||
|
closed, and the console reads and writes the level at once rather than
|
||||||
|
from what it loaded.
|
||||||
|
- **Similar questions about different pictures got each other's answer.**
|
||||||
|
A question with a picture, a sound or a file in it is reused only when it
|
||||||
|
is the same request.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **A card in the wrong PCIe slot is said so.** Each agent reads how every
|
||||||
|
card is linked — the fastest generation card and slot allow together, and
|
||||||
|
the lanes in use — against the card's own most, from the kernel (the
|
||||||
|
driver's figure is what card and slot allow together); a card on fewer lanes or
|
||||||
|
an older generation is a node warning, with what it carries and what it
|
||||||
|
could, on the dashboard, the console and in `llmgw doctor`.
|
||||||
|
- **And on a web page, `/admin/mcp`.** For administrators: the same list,
|
||||||
|
adding a server with its credential sealed, testing it and listing its
|
||||||
|
tools, granting them in the tree beside that level's other grants and
|
||||||
|
limits, revoking, changing, replacing the credential, turning it off,
|
||||||
|
removing; and the line that points an agent at this gateway's `/mcp`.
|
||||||
|
In English and Turkish; every change in the audit log, the secret not.
|
||||||
|
- **The company's MCP servers in the console.** A screen of its own: each
|
||||||
|
server with its state, who has its tools, its rate, the names of the
|
||||||
|
credentials it is sent and its address; add one with a bearer token or
|
||||||
|
a header kept sealed, test it and list its tools, grant them to a team,
|
||||||
|
project, organization, person or key beside that level's other grants,
|
||||||
|
revoke, change, replace the credential, turn off, remove.
|
||||||
|
- **And on the cards, as this cluster would lay it out.** The explorer's
|
||||||
|
**GPU tok/s** and a repository's files give one reply's speed for each
|
||||||
|
layout the planner offers here — one card, tensor parallel across a
|
||||||
|
node's cards, llama.cpp's layer split, a pipeline across nodes on a
|
||||||
|
fabric — the fastest of them, with the layout in the tooltip. From each
|
||||||
|
card's memory bandwidth by its model name, NVLink or PCIe between a
|
||||||
|
node's cards, and InfiniBand or RoCE between nodes; a cluster of mixed
|
||||||
|
classes is judged class by class. A model of experts reads 1.6 times its
|
||||||
|
active share, as Qwen3-Coder-30B-A3B did on cuda01's RTX 6000 (144
|
||||||
|
tok/s; qwen3-vl-8b Q8: 62).
|
||||||
|
- **How fast a model would answer on a CPU, before it is fetched.** Each
|
||||||
|
agent measures how fast its node's memory reads when it starts (`Host`
|
||||||
|
gets `mem_bandwidth_gbs`, the node's view `mem_bandwidth_gbs`); the
|
||||||
|
explorer's **CPU tok/s** column and a repository's GGUF files say about
|
||||||
|
how many tokens a second one reply gets on the fastest node that holds
|
||||||
|
the model — the bandwidth over the bytes a token reads, its active part
|
||||||
|
for a model of experts. Calibrated on the PoC against llama.cpp's own
|
||||||
|
timings: within 15% from 1.5B to 14B.
|
||||||
|
- **The explorer lists what this cluster can run.** Judged by the nodes
|
||||||
|
as they are now — every card's memory and compute capability, every
|
||||||
|
node's RAM — through the same planner as serving a model, rather than a
|
||||||
|
size picked from a list. A model too big for any node, in FP8 without a
|
||||||
|
card of 8.9, in MLX, or in neither GGUF nor safetensors, is left out and
|
||||||
|
counted; the page says what it judged by. With cards, safetensors repositories are
|
||||||
|
listed beside GGUF. On a repository's page a file too big for the
|
||||||
|
cluster is shown with what it needs and no Fetch button.
|
||||||
|
- **Weights that run code are refused.** Python pickles — `.bin`, `.pt`,
|
||||||
|
`.pth`, `.ckpt` — run whatever they hold when loaded, which is how the
|
||||||
|
malicious models on Hugging Face work. The node refuses such a source,
|
||||||
|
or a mirror version with one in it, before fetching it; adding such a
|
||||||
|
model is refused; vLLM and SGLang downloading a model by its id read its
|
||||||
|
safetensors only; the mirror page marks the file. `allow_pickle: true`
|
||||||
|
on a model lets it through on purpose. The mirror's explorer no longer
|
||||||
|
lists a repository with nothing a node loads (no GGUF, no safetensors)
|
||||||
|
while "runs here" is ticked, and a repository's pickle files are not
|
||||||
|
offered, only counted. BACKLOG M lists what is next:
|
||||||
|
checksums against Hugging Face everywhere, its own scan, trusted
|
||||||
|
publishers, engines without the internet.
|
||||||
|
|
||||||
|
## 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`
|
- **Setup asks where alerts go**: `llmgw server init --webhook URL`
|
||||||
(repeatable) and a **Notification webhooks** field on both setup screens.
|
(repeatable) and a **Notification webhooks** field on both setup screens.
|
||||||
Slack's, Mattermost's and Rocket.Chat's addresses are sent Slack's text,
|
Slack's, Mattermost's and Rocket.Chat's addresses are sent Slack's text,
|
||||||
@@ -48,6 +896,51 @@ APIs change between minors without a deprecation period.
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- 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
|
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.
|
a time and eight in all, on the leader as before.
|
||||||
|
|||||||
@@ -40,7 +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.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.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves; v0.14.6: setup that explains every part and lets it be chosen, the gateway on all three HA machines; v0.14.7: the judge sized for its machine; v0.14.8: sign-in with Active Directory, several keys for one outside provider; v0.14.9: sign-in set up from the console, a node's console that shows the machine) |
|
||||||
|
| v0.15.0 | — | The company's MCP tools and VS Code through the gateway; guardrails that ask outside services and put redacted values back; answers to similar questions | released (v0.15.1: Strata, a 125B model of experts on one card; a second mirror kept in step; pickled weights refused; a served model's context changed; v0.15.2: a served model moved to more cards; v0.15.3: a model stopped and kept, or removed with its files) |
|
||||||
|
| v0.16.0 | — | Weights pinned to a commit and checked against what Hugging Face published; documents embedded by the gateway; Strata offered where models are chosen; cluster upgrades that say how far they are | released (v0.16.1: the mirror's Strata tab, Strata's ready-made packs; v0.16.2: standbys that answer clients, FreeToken, a model fetched once per node; v0.16.3: replicas per profile, a served model's settings on the mirror page, the CPU's speed before serving) |
|
||||||
| v1.0.0 | 5 | What phase 5 has left: precise KV routing proven on vLLM, the block cost picker, defragmentation across NVLink domains, prefill/decode disaggregation | planned |
|
| 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
|
||||||
@@ -50,6 +52,17 @@ 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
|
- **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
|
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
|
kind, the leader adds it on demand, so a new cluster's portal has its
|
||||||
@@ -172,6 +185,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
|
||||||
@@ -246,6 +261,33 @@ Add `--json` to any command for machine-readable output.
|
|||||||
says nothing about which of six constraints bit, so it prints every candidate
|
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
|
||||||
@@ -273,8 +315,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
|
||||||
|
|||||||
+211
-26
@@ -1206,9 +1206,16 @@ type Host struct {
|
|||||||
CpuFlags []string `protobuf:"bytes,5,rep,name=cpu_flags,json=cpuFlags,proto3" json:"cpu_flags,omitempty"`
|
CpuFlags []string `protobuf:"bytes,5,rep,name=cpu_flags,json=cpuFlags,proto3" json:"cpu_flags,omitempty"`
|
||||||
// nvidia_driver is the driver version, empty when there is none. A card
|
// nvidia_driver is the driver version, empty when there is none. A card
|
||||||
// without a driver is not a GPU anything can use.
|
// without a driver is not a GPU anything can use.
|
||||||
NvidiaDriver string `protobuf:"bytes,6,opt,name=nvidia_driver,json=nvidiaDriver,proto3" json:"nvidia_driver,omitempty"`
|
NvidiaDriver string `protobuf:"bytes,6,opt,name=nvidia_driver,json=nvidiaDriver,proto3" json:"nvidia_driver,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
// mem_bandwidth_gbs is how fast the memory reads, in GB/s, measured when
|
||||||
sizeCache protoimpl.SizeCache
|
// the agent started: what decides a model's speed on the CPU. 0 unknown.
|
||||||
|
MemBandwidthGbs float64 `protobuf:"fixed64,7,opt,name=mem_bandwidth_gbs,json=memBandwidthGbs,proto3" json:"mem_bandwidth_gbs,omitempty"`
|
||||||
|
// mem_bandwidth_core_gbs is what one core reads alone, in GB/s: a replica
|
||||||
|
// given a few threads of a large machine reads at a few times this, not at
|
||||||
|
// the whole machine's rate. 0 unknown.
|
||||||
|
MemBandwidthCoreGbs float64 `protobuf:"fixed64,8,opt,name=mem_bandwidth_core_gbs,json=memBandwidthCoreGbs,proto3" json:"mem_bandwidth_core_gbs,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Host) Reset() {
|
func (x *Host) Reset() {
|
||||||
@@ -1283,6 +1290,20 @@ func (x *Host) GetNvidiaDriver() string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *Host) GetMemBandwidthGbs() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MemBandwidthGbs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Host) GetMemBandwidthCoreGbs() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MemBandwidthCoreGbs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// Storage is one filesystem a node keeps weights on.
|
// Storage is one filesystem a node keeps weights on.
|
||||||
//
|
//
|
||||||
// Per filesystem rather than per configured directory: free space belongs to
|
// Per filesystem rather than per configured directory: free space belongs to
|
||||||
@@ -1579,7 +1600,14 @@ type GPU struct {
|
|||||||
// is then 1000 + 100 × card + device, uuid the instance's (MIG-…), and
|
// is then 1000 + 100 × card + device, uuid the instance's (MIG-…), and
|
||||||
// memory the instance's share; utilization_percent stays zero, because no
|
// memory the instance's share; utilization_percent stays zero, because no
|
||||||
// driver measures it per instance.
|
// driver measures it per instance.
|
||||||
Mig *MIGDevice `protobuf:"bytes,22,opt,name=mig,proto3" json:"mig,omitempty"`
|
Mig *MIGDevice `protobuf:"bytes,22,opt,name=mig,proto3" json:"mig,omitempty"`
|
||||||
|
// How the card is linked to its host: the fastest generation card and
|
||||||
|
// slot allow together, the card's own fastest, and the lanes in use and
|
||||||
|
// the card's most. Zero when the driver did not say.
|
||||||
|
PcieGen int32 `protobuf:"varint,23,opt,name=pcie_gen,json=pcieGen,proto3" json:"pcie_gen,omitempty"`
|
||||||
|
PcieGpuGen int32 `protobuf:"varint,24,opt,name=pcie_gpu_gen,json=pcieGpuGen,proto3" json:"pcie_gpu_gen,omitempty"`
|
||||||
|
PcieWidth int32 `protobuf:"varint,25,opt,name=pcie_width,json=pcieWidth,proto3" json:"pcie_width,omitempty"`
|
||||||
|
PcieWidthMax int32 `protobuf:"varint,26,opt,name=pcie_width_max,json=pcieWidthMax,proto3" json:"pcie_width_max,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -1768,6 +1796,34 @@ func (x *GPU) GetMig() *MIGDevice {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *GPU) GetPcieGen() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.PcieGen
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GPU) GetPcieGpuGen() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.PcieGpuGen
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GPU) GetPcieWidth() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.PcieWidth
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GPU) GetPcieWidthMax() int32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.PcieWidthMax
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// MIGDevice is which card a MIG instance is carved from, and how.
|
// MIGDevice is which card a MIG instance is carved from, and how.
|
||||||
type MIGDevice struct {
|
type MIGDevice struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@@ -2174,12 +2230,27 @@ type GPUSensor struct {
|
|||||||
NvlinkReplayErrors int64 `protobuf:"varint,23,opt,name=nvlink_replay_errors,json=nvlinkReplayErrors,proto3" json:"nvlink_replay_errors,omitempty"`
|
NvlinkReplayErrors int64 `protobuf:"varint,23,opt,name=nvlink_replay_errors,json=nvlinkReplayErrors,proto3" json:"nvlink_replay_errors,omitempty"`
|
||||||
NvlinkRecoveryErrors int64 `protobuf:"varint,24,opt,name=nvlink_recovery_errors,json=nvlinkRecoveryErrors,proto3" json:"nvlink_recovery_errors,omitempty"`
|
NvlinkRecoveryErrors int64 `protobuf:"varint,24,opt,name=nvlink_recovery_errors,json=nvlinkRecoveryErrors,proto3" json:"nvlink_recovery_errors,omitempty"`
|
||||||
// xid_errors are the driver's error events since the agent started.
|
// xid_errors are the driver's error events since the agent started.
|
||||||
XidErrors int64 `protobuf:"varint,25,opt,name=xid_errors,json=xidErrors,proto3" json:"xid_errors,omitempty"`
|
XidErrors int64 `protobuf:"varint,25,opt,name=xid_errors,json=xidErrors,proto3" json:"xid_errors,omitempty"`
|
||||||
LastXid int32 `protobuf:"varint,26,opt,name=last_xid,json=lastXid,proto3" json:"last_xid,omitempty"`
|
LastXid int32 `protobuf:"varint,26,opt,name=last_xid,json=lastXid,proto3" json:"last_xid,omitempty"`
|
||||||
LastXidText string `protobuf:"bytes,27,opt,name=last_xid_text,json=lastXidText,proto3" json:"last_xid_text,omitempty"`
|
LastXidText string `protobuf:"bytes,27,opt,name=last_xid_text,json=lastXidText,proto3" json:"last_xid_text,omitempty"`
|
||||||
LastXidUnix int64 `protobuf:"varint,28,opt,name=last_xid_unix,json=lastXidUnix,proto3" json:"last_xid_unix,omitempty"`
|
LastXidUnix int64 `protobuf:"varint,28,opt,name=last_xid_unix,json=lastXidUnix,proto3" json:"last_xid_unix,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
// How much of the chip the work on it uses, which utilization_percent
|
||||||
sizeCache protoimpl.SizeCache
|
// does not say: that is the share of time any kernel ran, and a kernel on
|
||||||
|
// one SM of a hundred counts in full. has_profile says these are present;
|
||||||
|
// -1 is not reported. Read from NVML's GPM on Hopper and later, or from a
|
||||||
|
// dcgm-exporter on the node; profile_source says which ("gpm", "dcgm").
|
||||||
|
HasProfile bool `protobuf:"varint,29,opt,name=has_profile,json=hasProfile,proto3" json:"has_profile,omitempty"`
|
||||||
|
// sm_active_percent is the share of time the SMs had work, averaged over
|
||||||
|
// all of them; sm_occupancy_percent how full those SMs were with warps.
|
||||||
|
SmActivePercent float64 `protobuf:"fixed64,30,opt,name=sm_active_percent,json=smActivePercent,proto3" json:"sm_active_percent,omitempty"`
|
||||||
|
SmOccupancyPercent float64 `protobuf:"fixed64,31,opt,name=sm_occupancy_percent,json=smOccupancyPercent,proto3" json:"sm_occupancy_percent,omitempty"`
|
||||||
|
// tensor_active_percent is the tensor cores; dram_active_percent the
|
||||||
|
// memory interface, which decode is bound by.
|
||||||
|
TensorActivePercent float64 `protobuf:"fixed64,32,opt,name=tensor_active_percent,json=tensorActivePercent,proto3" json:"tensor_active_percent,omitempty"`
|
||||||
|
DramActivePercent float64 `protobuf:"fixed64,33,opt,name=dram_active_percent,json=dramActivePercent,proto3" json:"dram_active_percent,omitempty"`
|
||||||
|
ProfileSource string `protobuf:"bytes,34,opt,name=profile_source,json=profileSource,proto3" json:"profile_source,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *GPUSensor) Reset() {
|
func (x *GPUSensor) Reset() {
|
||||||
@@ -2408,6 +2479,48 @@ func (x *GPUSensor) GetLastXidUnix() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *GPUSensor) GetHasProfile() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.HasProfile
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GPUSensor) GetSmActivePercent() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.SmActivePercent
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GPUSensor) GetSmOccupancyPercent() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.SmOccupancyPercent
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GPUSensor) GetTensorActivePercent() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.TensorActivePercent
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GPUSensor) GetDramActivePercent() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.DramActivePercent
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *GPUSensor) GetProfileSource() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ProfileSource
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// ShardLoad is one engine process group's use.
|
// ShardLoad is one engine process group's use.
|
||||||
type ShardLoad struct {
|
type ShardLoad struct {
|
||||||
state protoimpl.MessageState `protogen:"open.v1"`
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
@@ -2943,8 +3056,14 @@ type Heartbeat struct {
|
|||||||
// is not handed out. Empty from an agent that does not measure it, or from
|
// is not handed out. Empty from an agent that does not measure it, or from
|
||||||
// one that could not this time.
|
// one that could not this time.
|
||||||
GpuForeignMemoryMb []int32 `protobuf:"varint,19,rep,packed,name=gpu_foreign_memory_mb,json=gpuForeignMemoryMb,proto3" json:"gpu_foreign_memory_mb,omitempty"`
|
GpuForeignMemoryMb []int32 `protobuf:"varint,19,rep,packed,name=gpu_foreign_memory_mb,json=gpuForeignMemoryMb,proto3" json:"gpu_foreign_memory_mb,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
// mem_bandwidth_gbs is how fast the node's memory reads, in GB/s, once
|
||||||
sizeCache protoimpl.SizeCache
|
// the agent has measured it — in the background after it starts, so a
|
||||||
|
// registration usually has none yet. 0 means not measured.
|
||||||
|
MemBandwidthGbs float64 `protobuf:"fixed64,20,opt,name=mem_bandwidth_gbs,json=memBandwidthGbs,proto3" json:"mem_bandwidth_gbs,omitempty"`
|
||||||
|
// mem_bandwidth_core_gbs is one core's read rate, as in Host.
|
||||||
|
MemBandwidthCoreGbs float64 `protobuf:"fixed64,21,opt,name=mem_bandwidth_core_gbs,json=memBandwidthCoreGbs,proto3" json:"mem_bandwidth_core_gbs,omitempty"`
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
|
|
||||||
func (x *Heartbeat) Reset() {
|
func (x *Heartbeat) Reset() {
|
||||||
@@ -3110,6 +3229,20 @@ func (x *Heartbeat) GetGpuForeignMemoryMb() []int32 {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *Heartbeat) GetMemBandwidthGbs() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MemBandwidthGbs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *Heartbeat) GetMemBandwidthCoreGbs() float64 {
|
||||||
|
if x != nil {
|
||||||
|
return x.MemBandwidthCoreGbs
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// Provision is the software a node should have: which engines, at which
|
// Provision is the software a node should have: which engines, at which
|
||||||
// versions. Nothing more.
|
// versions. Nothing more.
|
||||||
//
|
//
|
||||||
@@ -3530,7 +3663,10 @@ type FetchProgress struct {
|
|||||||
// bytes_total is 0 when the server did not say how large the file is, which
|
// bytes_total is 0 when the server did not say how large the file is, which
|
||||||
// is a real answer: a percentage would then be invented, and an invented
|
// is a real answer: a percentage would then be invented, and an invented
|
||||||
// percentage that stops moving is worse than a byte count that keeps rising.
|
// percentage that stops moving is worse than a byte count that keeps rising.
|
||||||
BytesTotal int64 `protobuf:"varint,3,opt,name=bytes_total,json=bytesTotal,proto3" json:"bytes_total,omitempty"`
|
BytesTotal int64 `protobuf:"varint,3,opt,name=bytes_total,json=bytesTotal,proto3" json:"bytes_total,omitempty"`
|
||||||
|
// phase is what the bytes are: empty while they arrive, "checking" while the
|
||||||
|
// node checks a set's files against the mirror's checksums.
|
||||||
|
Phase string `protobuf:"bytes,4,opt,name=phase,proto3" json:"phase,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -3586,6 +3722,13 @@ func (x *FetchProgress) GetBytesTotal() int64 {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *FetchProgress) GetPhase() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Phase
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// StartShard launches one shard. It is idempotent by shard_id: the control
|
// StartShard launches one shard. It is idempotent by shard_id: the control
|
||||||
// plane retries, and a repeat must not produce a second process.
|
// plane retries, and a repeat must not produce a second process.
|
||||||
type StartShard struct {
|
type StartShard struct {
|
||||||
@@ -3790,7 +3933,14 @@ type EngineProfile struct {
|
|||||||
Adapters []*LoraAdapter `protobuf:"bytes,12,rep,name=adapters,proto3" json:"adapters,omitempty"`
|
Adapters []*LoraAdapter `protobuf:"bytes,12,rep,name=adapters,proto3" json:"adapters,omitempty"`
|
||||||
// kv_events has a vLLM engine publish its KV cache events to the agent, for
|
// kv_events has a vLLM engine publish its KV cache events to the agent, for
|
||||||
// the router's precise index.
|
// the router's precise index.
|
||||||
KvEvents bool `protobuf:"varint,13,opt,name=kv_events,json=kvEvents,proto3" json:"kv_events,omitempty"`
|
KvEvents bool `protobuf:"varint,13,opt,name=kv_events,json=kvEvents,proto3" json:"kv_events,omitempty"`
|
||||||
|
// allow_pickle lets the node fetch weights in a pickle format, which run
|
||||||
|
// code when loaded: the catalog's allow_pickle, set on purpose.
|
||||||
|
AllowPickle bool `protobuf:"varint,14,opt,name=allow_pickle,json=allowPickle,proto3" json:"allow_pickle,omitempty"`
|
||||||
|
// model_sha256 is the sha256 the registry published for the file
|
||||||
|
// model_path names, from the catalog's origin: the node refuses a copy
|
||||||
|
// that is not it. Empty when nothing is known to check against.
|
||||||
|
ModelSha256 string `protobuf:"bytes,15,opt,name=model_sha256,json=modelSha256,proto3" json:"model_sha256,omitempty"`
|
||||||
unknownFields protoimpl.UnknownFields
|
unknownFields protoimpl.UnknownFields
|
||||||
sizeCache protoimpl.SizeCache
|
sizeCache protoimpl.SizeCache
|
||||||
}
|
}
|
||||||
@@ -3916,6 +4066,20 @@ func (x *EngineProfile) GetKvEvents() bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (x *EngineProfile) GetAllowPickle() bool {
|
||||||
|
if x != nil {
|
||||||
|
return x.AllowPickle
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *EngineProfile) GetModelSha256() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.ModelSha256
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
// RemoveWeights deletes cached model files a node no longer needs.
|
// RemoveWeights deletes cached model files a node no longer needs.
|
||||||
//
|
//
|
||||||
// The paths are computed by the control plane from the catalog, but the agent
|
// The paths are computed by the control plane from the catalog, but the agent
|
||||||
@@ -5180,7 +5344,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aA\n" +
|
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aA\n" +
|
||||||
"\x13EngineVersionsEntry\x12\x10\n" +
|
"\x13EngineVersionsEntry\x12\x10\n" +
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb1\x01\n" +
|
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x92\x02\n" +
|
||||||
"\x04Host\x12\x13\n" +
|
"\x04Host\x12\x13\n" +
|
||||||
"\x05os_id\x18\x01 \x01(\tR\x04osId\x12\x1d\n" +
|
"\x05os_id\x18\x01 \x01(\tR\x04osId\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
@@ -5189,7 +5353,9 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"osCodename\x12\x12\n" +
|
"osCodename\x12\x12\n" +
|
||||||
"\x04arch\x18\x04 \x01(\tR\x04arch\x12\x1b\n" +
|
"\x04arch\x18\x04 \x01(\tR\x04arch\x12\x1b\n" +
|
||||||
"\tcpu_flags\x18\x05 \x03(\tR\bcpuFlags\x12#\n" +
|
"\tcpu_flags\x18\x05 \x03(\tR\bcpuFlags\x12#\n" +
|
||||||
"\rnvidia_driver\x18\x06 \x01(\tR\fnvidiaDriver\"u\n" +
|
"\rnvidia_driver\x18\x06 \x01(\tR\fnvidiaDriver\x12*\n" +
|
||||||
|
"\x11mem_bandwidth_gbs\x18\a \x01(\x01R\x0fmemBandwidthGbs\x123\n" +
|
||||||
|
"\x16mem_bandwidth_core_gbs\x18\b \x01(\x01R\x13memBandwidthCoreGbs\"u\n" +
|
||||||
"\aStorage\x12\x12\n" +
|
"\aStorage\x12\x12\n" +
|
||||||
"\x04path\x18\x01 \x01(\tR\x04path\x12\x1d\n" +
|
"\x04path\x18\x01 \x01(\tR\x04path\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
@@ -5219,7 +5385,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"leaderPort\x12\x1d\n" +
|
"leaderPort\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"profile_fp\x18\x0f \x01(\tR\tprofileFp\x12+\n" +
|
"profile_fp\x18\x0f \x01(\tR\tprofileFp\x12+\n" +
|
||||||
"\acompute\x18\x10 \x01(\v2\x11.llmgw.v1.ComputeR\acompute\"\xca\x06\n" +
|
"\acompute\x18\x10 \x01(\v2\x11.llmgw.v1.ComputeR\acompute\"\xcc\a\n" +
|
||||||
"\x03GPU\x12\x14\n" +
|
"\x03GPU\x12\x14\n" +
|
||||||
"\x05index\x18\x01 \x01(\x05R\x05index\x12\x12\n" +
|
"\x05index\x18\x01 \x01(\x05R\x05index\x12\x12\n" +
|
||||||
"\x04uuid\x18\x02 \x01(\tR\x04uuid\x12\x12\n" +
|
"\x04uuid\x18\x02 \x01(\tR\x04uuid\x12\x12\n" +
|
||||||
@@ -5245,7 +5411,13 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"\fcluster_uuid\x18\x13 \x01(\tR\vclusterUuid\x12\x1b\n" +
|
"\fcluster_uuid\x18\x13 \x01(\tR\vclusterUuid\x12\x1b\n" +
|
||||||
"\tclique_id\x18\x14 \x01(\tR\bcliqueId\x12)\n" +
|
"\tclique_id\x18\x14 \x01(\tR\bcliqueId\x12)\n" +
|
||||||
"\x10fabric_bandwidth\x18\x15 \x01(\tR\x0ffabricBandwidth\x12%\n" +
|
"\x10fabric_bandwidth\x18\x15 \x01(\tR\x0ffabricBandwidth\x12%\n" +
|
||||||
"\x03mig\x18\x16 \x01(\v2\x13.llmgw.v1.MIGDeviceR\x03mig\x1a;\n" +
|
"\x03mig\x18\x16 \x01(\v2\x13.llmgw.v1.MIGDeviceR\x03mig\x12\x19\n" +
|
||||||
|
"\bpcie_gen\x18\x17 \x01(\x05R\apcieGen\x12 \n" +
|
||||||
|
"\fpcie_gpu_gen\x18\x18 \x01(\x05R\n" +
|
||||||
|
"pcieGpuGen\x12\x1d\n" +
|
||||||
|
"\n" +
|
||||||
|
"pcie_width\x18\x19 \x01(\x05R\tpcieWidth\x12$\n" +
|
||||||
|
"\x0epcie_width_max\x18\x1a \x01(\x05R\fpcieWidthMax\x1a;\n" +
|
||||||
"\rNicPathsEntry\x12\x10\n" +
|
"\rNicPathsEntry\x12\x10\n" +
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x84\x02\n" +
|
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x84\x02\n" +
|
||||||
@@ -5283,7 +5455,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
" \x01(\x01R\tcpuPowerW\x12 \n" +
|
" \x01(\x01R\tcpuPowerW\x12 \n" +
|
||||||
"\finlet_temp_c\x18\v \x01(\x01R\n" +
|
"\finlet_temp_c\x18\v \x01(\x01R\n" +
|
||||||
"inletTempC\x12'\n" +
|
"inletTempC\x12'\n" +
|
||||||
"\x04gpus\x18\f \x03(\v2\x13.llmgw.v1.GPUSensorR\x04gpus\"\xde\b\n" +
|
"\x04gpus\x18\f \x03(\v2\x13.llmgw.v1.GPUSensorR\x04gpus\"\xe8\n" +
|
||||||
|
"\n" +
|
||||||
"\tGPUSensor\x12\x14\n" +
|
"\tGPUSensor\x12\x14\n" +
|
||||||
"\x05index\x18\x01 \x01(\x05R\x05index\x12\x15\n" +
|
"\x05index\x18\x01 \x01(\x05R\x05index\x12\x15\n" +
|
||||||
"\x06temp_c\x18\x02 \x01(\x01R\x05tempC\x12\"\n" +
|
"\x06temp_c\x18\x02 \x01(\x01R\x05tempC\x12\"\n" +
|
||||||
@@ -5315,7 +5488,14 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"xid_errors\x18\x19 \x01(\x03R\txidErrors\x12\x19\n" +
|
"xid_errors\x18\x19 \x01(\x03R\txidErrors\x12\x19\n" +
|
||||||
"\blast_xid\x18\x1a \x01(\x05R\alastXid\x12\"\n" +
|
"\blast_xid\x18\x1a \x01(\x05R\alastXid\x12\"\n" +
|
||||||
"\rlast_xid_text\x18\x1b \x01(\tR\vlastXidText\x12\"\n" +
|
"\rlast_xid_text\x18\x1b \x01(\tR\vlastXidText\x12\"\n" +
|
||||||
"\rlast_xid_unix\x18\x1c \x01(\x03R\vlastXidUnix\"m\n" +
|
"\rlast_xid_unix\x18\x1c \x01(\x03R\vlastXidUnix\x12\x1f\n" +
|
||||||
|
"\vhas_profile\x18\x1d \x01(\bR\n" +
|
||||||
|
"hasProfile\x12*\n" +
|
||||||
|
"\x11sm_active_percent\x18\x1e \x01(\x01R\x0fsmActivePercent\x120\n" +
|
||||||
|
"\x14sm_occupancy_percent\x18\x1f \x01(\x01R\x12smOccupancyPercent\x122\n" +
|
||||||
|
"\x15tensor_active_percent\x18 \x01(\x01R\x13tensorActivePercent\x12.\n" +
|
||||||
|
"\x13dram_active_percent\x18! \x01(\x01R\x11dramActivePercent\x12%\n" +
|
||||||
|
"\x0eprofile_source\x18\" \x01(\tR\rprofileSource\"m\n" +
|
||||||
"\tShardLoad\x12\x19\n" +
|
"\tShardLoad\x12\x19\n" +
|
||||||
"\bshard_id\x18\x01 \x01(\tR\ashardId\x12\x1b\n" +
|
"\bshard_id\x18\x01 \x01(\tR\ashardId\x12\x1b\n" +
|
||||||
"\tcpu_cores\x18\x02 \x01(\x01R\bcpuCores\x12(\n" +
|
"\tcpu_cores\x18\x02 \x01(\x01R\bcpuCores\x12(\n" +
|
||||||
@@ -5361,7 +5541,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"\x06status\x18\x03 \x01(\tR\x06status\x12!\n" +
|
"\x06status\x18\x03 \x01(\tR\x06status\x12!\n" +
|
||||||
"\fcluster_uuid\x18\x04 \x01(\tR\vclusterUuid\x12\x1b\n" +
|
"\fcluster_uuid\x18\x04 \x01(\tR\vclusterUuid\x12\x1b\n" +
|
||||||
"\tclique_id\x18\x05 \x01(\tR\bcliqueId\x12\x1c\n" +
|
"\tclique_id\x18\x05 \x01(\tR\bcliqueId\x12\x1c\n" +
|
||||||
"\tbandwidth\x18\x06 \x01(\tR\tbandwidth\"\xa9\a\n" +
|
"\tbandwidth\x18\x06 \x01(\tR\tbandwidth\"\x8a\b\n" +
|
||||||
"\tHeartbeat\x12\x17\n" +
|
"\tHeartbeat\x12\x17\n" +
|
||||||
"\anode_id\x18\x01 \x01(\tR\x06nodeId\x12+\n" +
|
"\anode_id\x18\x01 \x01(\tR\x06nodeId\x12+\n" +
|
||||||
"\x12gpu_memory_used_mb\x18\x02 \x03(\x05R\x0fgpuMemoryUsedMb\x12!\n" +
|
"\x12gpu_memory_used_mb\x18\x02 \x03(\x05R\x0fgpuMemoryUsedMb\x12!\n" +
|
||||||
@@ -5383,7 +5563,9 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"gpu_fabric\x18\x10 \x03(\v2\x18.llmgw.v1.GPUFabricStateR\tgpuFabric\x12\"\n" +
|
"gpu_fabric\x18\x10 \x03(\v2\x18.llmgw.v1.GPUFabricStateR\tgpuFabric\x12\"\n" +
|
||||||
"\x04imex\x18\x11 \x01(\v2\x0e.llmgw.v1.IMEXR\x04imex\x12\x1b\n" +
|
"\x04imex\x18\x11 \x01(\v2\x0e.llmgw.v1.IMEXR\x04imex\x12\x1b\n" +
|
||||||
"\tcpu_count\x18\x12 \x01(\x05R\bcpuCount\x121\n" +
|
"\tcpu_count\x18\x12 \x01(\x05R\bcpuCount\x121\n" +
|
||||||
"\x15gpu_foreign_memory_mb\x18\x13 \x03(\x05R\x12gpuForeignMemoryMb\x1aA\n" +
|
"\x15gpu_foreign_memory_mb\x18\x13 \x03(\x05R\x12gpuForeignMemoryMb\x12*\n" +
|
||||||
|
"\x11mem_bandwidth_gbs\x18\x14 \x01(\x01R\x0fmemBandwidthGbs\x123\n" +
|
||||||
|
"\x16mem_bandwidth_core_gbs\x18\x15 \x01(\x01R\x13memBandwidthCoreGbs\x1aA\n" +
|
||||||
"\x13EngineVersionsEntry\x12\x10\n" +
|
"\x13EngineVersionsEntry\x12\x10\n" +
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\";\n" +
|
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\";\n" +
|
||||||
@@ -5430,13 +5612,14 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"\vSTATE_READY\x10\x04\x12\x12\n" +
|
"\vSTATE_READY\x10\x04\x12\x12\n" +
|
||||||
"\x0eSTATE_DRAINING\x10\x05\x12\x11\n" +
|
"\x0eSTATE_DRAINING\x10\x05\x12\x11\n" +
|
||||||
"\rSTATE_STOPPED\x10\x06\x12\x10\n" +
|
"\rSTATE_STOPPED\x10\x06\x12\x10\n" +
|
||||||
"\fSTATE_FAILED\x10\a\"g\n" +
|
"\fSTATE_FAILED\x10\a\"}\n" +
|
||||||
"\rFetchProgress\x12\x16\n" +
|
"\rFetchProgress\x12\x16\n" +
|
||||||
"\x06source\x18\x01 \x01(\tR\x06source\x12\x1d\n" +
|
"\x06source\x18\x01 \x01(\tR\x06source\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"bytes_done\x18\x02 \x01(\x03R\tbytesDone\x12\x1f\n" +
|
"bytes_done\x18\x02 \x01(\x03R\tbytesDone\x12\x1f\n" +
|
||||||
"\vbytes_total\x18\x03 \x01(\x03R\n" +
|
"\vbytes_total\x18\x03 \x01(\x03R\n" +
|
||||||
"bytesTotal\"\x93\x04\n" +
|
"bytesTotal\x12\x14\n" +
|
||||||
|
"\x05phase\x18\x04 \x01(\tR\x05phase\"\x93\x04\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"StartShard\x12\x1d\n" +
|
"StartShard\x12\x1d\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
@@ -5462,7 +5645,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
"profile_fp\x18\x0f \x01(\tR\tprofileFp\x12\x17\n" +
|
"profile_fp\x18\x0f \x01(\tR\tprofileFp\x12\x17\n" +
|
||||||
"\aapi_key\x18\x10 \x01(\tR\x06apiKey\x12\x1f\n" +
|
"\aapi_key\x18\x10 \x01(\tR\x06apiKey\x12\x1f\n" +
|
||||||
"\vcpu_threads\x18\x11 \x01(\x05R\n" +
|
"\vcpu_threads\x18\x11 \x01(\x05R\n" +
|
||||||
"cpuThreads\"\xcd\x03\n" +
|
"cpuThreads\"\x93\x04\n" +
|
||||||
"\rEngineProfile\x12\x12\n" +
|
"\rEngineProfile\x12\x12\n" +
|
||||||
"\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" +
|
"\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" +
|
||||||
"\x06engine\x18\x02 \x01(\tR\x06engine\x12\x1b\n" +
|
"\x06engine\x18\x02 \x01(\tR\x06engine\x12\x1b\n" +
|
||||||
@@ -5478,7 +5661,9 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
|||||||
" \x01(\tR\tmodelPath\x12*\n" +
|
" \x01(\tR\tmodelPath\x12*\n" +
|
||||||
"\x11served_model_name\x18\v \x01(\tR\x0fservedModelName\x121\n" +
|
"\x11served_model_name\x18\v \x01(\tR\x0fservedModelName\x121\n" +
|
||||||
"\badapters\x18\f \x03(\v2\x15.llmgw.v1.LoraAdapterR\badapters\x12\x1b\n" +
|
"\badapters\x18\f \x03(\v2\x15.llmgw.v1.LoraAdapterR\badapters\x12\x1b\n" +
|
||||||
"\tkv_events\x18\r \x01(\bR\bkvEvents\x1a6\n" +
|
"\tkv_events\x18\r \x01(\bR\bkvEvents\x12!\n" +
|
||||||
|
"\fallow_pickle\x18\x0e \x01(\bR\vallowPickle\x12!\n" +
|
||||||
|
"\fmodel_sha256\x18\x0f \x01(\tR\vmodelSha256\x1a6\n" +
|
||||||
"\bEnvEntry\x12\x10\n" +
|
"\bEnvEntry\x12\x10\n" +
|
||||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"%\n" +
|
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"%\n" +
|
||||||
|
|||||||
@@ -238,6 +238,13 @@ message Host {
|
|||||||
// nvidia_driver is the driver version, empty when there is none. A card
|
// nvidia_driver is the driver version, empty when there is none. A card
|
||||||
// without a driver is not a GPU anything can use.
|
// without a driver is not a GPU anything can use.
|
||||||
string nvidia_driver = 6;
|
string nvidia_driver = 6;
|
||||||
|
// mem_bandwidth_gbs is how fast the memory reads, in GB/s, measured when
|
||||||
|
// the agent started: what decides a model's speed on the CPU. 0 unknown.
|
||||||
|
double mem_bandwidth_gbs = 7;
|
||||||
|
// mem_bandwidth_core_gbs is what one core reads alone, in GB/s: a replica
|
||||||
|
// given a few threads of a large machine reads at a few times this, not at
|
||||||
|
// the whole machine's rate. 0 unknown.
|
||||||
|
double mem_bandwidth_core_gbs = 8;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Storage is one filesystem a node keeps weights on.
|
// Storage is one filesystem a node keeps weights on.
|
||||||
@@ -334,6 +341,13 @@ message GPU {
|
|||||||
// memory the instance's share; utilization_percent stays zero, because no
|
// memory the instance's share; utilization_percent stays zero, because no
|
||||||
// driver measures it per instance.
|
// driver measures it per instance.
|
||||||
MIGDevice mig = 22;
|
MIGDevice mig = 22;
|
||||||
|
// How the card is linked to its host: the fastest generation card and
|
||||||
|
// slot allow together, the card's own fastest, and the lanes in use and
|
||||||
|
// the card's most. Zero when the driver did not say.
|
||||||
|
int32 pcie_gen = 23;
|
||||||
|
int32 pcie_gpu_gen = 24;
|
||||||
|
int32 pcie_width = 25;
|
||||||
|
int32 pcie_width_max = 26;
|
||||||
}
|
}
|
||||||
|
|
||||||
// MIGDevice is which card a MIG instance is carved from, and how.
|
// MIGDevice is which card a MIG instance is carved from, and how.
|
||||||
@@ -438,6 +452,21 @@ message GPUSensor {
|
|||||||
int32 last_xid = 26;
|
int32 last_xid = 26;
|
||||||
string last_xid_text = 27;
|
string last_xid_text = 27;
|
||||||
int64 last_xid_unix = 28;
|
int64 last_xid_unix = 28;
|
||||||
|
// How much of the chip the work on it uses, which utilization_percent
|
||||||
|
// does not say: that is the share of time any kernel ran, and a kernel on
|
||||||
|
// one SM of a hundred counts in full. has_profile says these are present;
|
||||||
|
// -1 is not reported. Read from NVML's GPM on Hopper and later, or from a
|
||||||
|
// dcgm-exporter on the node; profile_source says which ("gpm", "dcgm").
|
||||||
|
bool has_profile = 29;
|
||||||
|
// sm_active_percent is the share of time the SMs had work, averaged over
|
||||||
|
// all of them; sm_occupancy_percent how full those SMs were with warps.
|
||||||
|
double sm_active_percent = 30;
|
||||||
|
double sm_occupancy_percent = 31;
|
||||||
|
// tensor_active_percent is the tensor cores; dram_active_percent the
|
||||||
|
// memory interface, which decode is bound by.
|
||||||
|
double tensor_active_percent = 32;
|
||||||
|
double dram_active_percent = 33;
|
||||||
|
string profile_source = 34;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShardLoad is one engine process group's use.
|
// ShardLoad is one engine process group's use.
|
||||||
@@ -588,6 +617,13 @@ message Heartbeat {
|
|||||||
// is not handed out. Empty from an agent that does not measure it, or from
|
// is not handed out. Empty from an agent that does not measure it, or from
|
||||||
// one that could not this time.
|
// one that could not this time.
|
||||||
repeated int32 gpu_foreign_memory_mb = 19;
|
repeated int32 gpu_foreign_memory_mb = 19;
|
||||||
|
|
||||||
|
// mem_bandwidth_gbs is how fast the node's memory reads, in GB/s, once
|
||||||
|
// the agent has measured it — in the background after it starts, so a
|
||||||
|
// registration usually has none yet. 0 means not measured.
|
||||||
|
double mem_bandwidth_gbs = 20;
|
||||||
|
// mem_bandwidth_core_gbs is one core's read rate, as in Host.
|
||||||
|
double mem_bandwidth_core_gbs = 21;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provision is the software a node should have: which engines, at which
|
// Provision is the software a node should have: which engines, at which
|
||||||
@@ -690,6 +726,9 @@ message FetchProgress {
|
|||||||
// is a real answer: a percentage would then be invented, and an invented
|
// is a real answer: a percentage would then be invented, and an invented
|
||||||
// percentage that stops moving is worse than a byte count that keeps rising.
|
// percentage that stops moving is worse than a byte count that keeps rising.
|
||||||
int64 bytes_total = 3;
|
int64 bytes_total = 3;
|
||||||
|
// phase is what the bytes are: empty while they arrive, "checking" while the
|
||||||
|
// node checks a set's files against the mirror's checksums.
|
||||||
|
string phase = 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartShard launches one shard. It is idempotent by shard_id: the control
|
// StartShard launches one shard. It is idempotent by shard_id: the control
|
||||||
@@ -749,6 +788,13 @@ message EngineProfile {
|
|||||||
// kv_events has a vLLM engine publish its KV cache events to the agent, for
|
// kv_events has a vLLM engine publish its KV cache events to the agent, for
|
||||||
// the router's precise index.
|
// the router's precise index.
|
||||||
bool kv_events = 13;
|
bool kv_events = 13;
|
||||||
|
// allow_pickle lets the node fetch weights in a pickle format, which run
|
||||||
|
// code when loaded: the catalog's allow_pickle, set on purpose.
|
||||||
|
bool allow_pickle = 14;
|
||||||
|
// model_sha256 is the sha256 the registry published for the file
|
||||||
|
// model_path names, from the catalog's origin: the node refuses a copy
|
||||||
|
// that is not it. Empty when nothing is known to check against.
|
||||||
|
string model_sha256 = 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveWeights deletes cached model files a node no longer needs.
|
// RemoveWeights deletes cached model files a node no longer needs.
|
||||||
|
|||||||
@@ -331,6 +331,8 @@ hidden files, version control folders and Office lock files are not.`,
|
|||||||
f.BoolVar(&o.Delete, "delete", false, "remove documents this machine sent whose files are gone")
|
f.BoolVar(&o.Delete, "delete", false, "remove documents this machine sent whose files are gone")
|
||||||
f.BoolVar(&o.Retry, "retry", false, "read again files skipped before")
|
f.BoolVar(&o.Retry, "retry", false, "read again files skipped before")
|
||||||
f.IntVar(&o.Workers, "workers", 4, "files read and sent at once")
|
f.IntVar(&o.Workers, "workers", 4, "files read and sent at once")
|
||||||
|
f.BoolVar(&o.EmbedHere, "embed-here", false,
|
||||||
|
"embed on this machine and send the vectors, even to a gateway that embeds text it is sent")
|
||||||
f.StringVar(&ocr, "ocr-languages", "tur+eng", "Tesseract's languages for images and scanned pages")
|
f.StringVar(&ocr, "ocr-languages", "tur+eng", "Tesseract's languages for images and scanned pages")
|
||||||
f.Int64Var(&maxMiB, "max-size", 256, "skip files larger than this many MiB")
|
f.Int64Var(&maxMiB, "max-size", 256, "skip files larger than this many MiB")
|
||||||
f.BoolVarP(&verbose, "verbose", "v", false, "list unchanged files too")
|
f.BoolVarP(&verbose, "verbose", "v", false, "list unchanged files too")
|
||||||
|
|||||||
+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.
|
||||||
|
|||||||
@@ -504,7 +504,7 @@
|
|||||||
{
|
{
|
||||||
"type": "timeseries",
|
"type": "timeseries",
|
||||||
"title": "GPU busy, by card",
|
"title": "GPU busy, by card",
|
||||||
"description": "Share of each sample period a kernel was running, sampled every heartbeat.",
|
"description": "Share of each sample period in which any kernel ran (nvidia-smi's utilization.gpu), sampled every heartbeat. Not how much of the chip: one SM busy reads 100%. The SMs panel and power against its limit say how much.",
|
||||||
"datasource": {
|
"datasource": {
|
||||||
"type": "prometheus",
|
"type": "prometheus",
|
||||||
"uid": "llmgw-prom"
|
"uid": "llmgw-prom"
|
||||||
@@ -908,6 +908,51 @@
|
|||||||
"mode": "multi"
|
"mode": "multi"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "timeseries",
|
||||||
|
"title": "GPU SMs and tensor cores active, by card",
|
||||||
|
"description": "From the cards' profiling counters: NVML's GPM on Hopper and later, or a dcgm-exporter on the node. SMs active is the share of time the SMs had work, averaged over all of them; the gap to busy is chip a running kernel leaves idle. Tensor cores stay low in decode at batch one; prefill and batches are where they rise. Empty for cards nothing can profile.",
|
||||||
|
"datasource": {
|
||||||
|
"type": "prometheus",
|
||||||
|
"uid": "llmgw-prom"
|
||||||
|
},
|
||||||
|
"gridPos": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 73,
|
||||||
|
"w": 24,
|
||||||
|
"h": 7
|
||||||
|
},
|
||||||
|
"id": 25,
|
||||||
|
"targets": [
|
||||||
|
{
|
||||||
|
"expr": "llmgw_gpu_sm_active_percent",
|
||||||
|
"legendFormat": "{{node}} gpu {{gpu}} SMs",
|
||||||
|
"refId": "A"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"expr": "llmgw_gpu_tensor_active_percent",
|
||||||
|
"legendFormat": "{{node}} gpu {{gpu}} tensor",
|
||||||
|
"refId": "B"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fieldConfig": {
|
||||||
|
"defaults": {
|
||||||
|
"unit": "percent",
|
||||||
|
"min": 0,
|
||||||
|
"max": 100
|
||||||
|
},
|
||||||
|
"overrides": []
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"legend": {
|
||||||
|
"displayMode": "list",
|
||||||
|
"placement": "bottom"
|
||||||
|
},
|
||||||
|
"tooltip": {
|
||||||
|
"mode": "multi"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
# }
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
Description=llmgw node agent
|
Description=llmgw node agent
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
# Never given up on: a new release that fails its first starts, or an upgrade
|
||||||
|
# that restarts it twice in a minute, would otherwise leave the node off.
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
|
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
|
||||||
|
|||||||
+1506
-1
File diff suppressed because it is too large.
Load diff
@@ -82,7 +82,7 @@
|
|||||||
"/mcp": {
|
"/mcp": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "postMcp",
|
"operationId": "postMcp",
|
||||||
"summary": "The gateway's MCP server: the memory notes as tools",
|
"summary": "The gateway's MCP server: the memory notes, and pictures, 3D models, vision and workflows, as tools",
|
||||||
"tags": [
|
"tags": [
|
||||||
"mcp"
|
"mcp"
|
||||||
],
|
],
|
||||||
@@ -2890,6 +2890,10 @@
|
|||||||
"writable": {
|
"writable": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "Writable is whether this caller may add and remove documents."
|
"description": "Writable is whether this caller may add and remove documents."
|
||||||
|
},
|
||||||
|
"server_embeds": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "ServerEmbeds says a document may be sent as text alone: the gateway embeds its chunks, as the caller, with the collection's model."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -534,6 +534,60 @@ 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
|
### Licences you leave out
|
||||||
|
|
||||||
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
|
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
|
||||||
|
|||||||
+56
-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.
|
||||||
|
|
||||||
@@ -173,6 +173,23 @@ search…** as an administrator, or **Delete…** it. People
|
|||||||
fill collections from their own machines with `llmgw-kb`
|
fill collections from their own machines with `llmgw-kb`
|
||||||
([knowledge.md](knowledge.md)). Off on a SQLite store, and says why.
|
([knowledge.md](knowledge.md)). Off on a SQLite store, and says why.
|
||||||
|
|
||||||
|
**MCP servers** lists the company's own MCP servers that agents reach through
|
||||||
|
the gateway's `/mcp`, their tools named `server.tool`: whether each is on,
|
||||||
|
who has been granted its tools, its rate per caller, the names of the
|
||||||
|
credentials it is sent (never their values) and its address. **A** adds one:
|
||||||
|
its name, which is its tools' prefix, its address, and a bearer token or
|
||||||
|
another header, kept sealed in the store. On a row: **Test, and list its
|
||||||
|
tools**, with its credential, as a call would; **Grant its tools to…** a
|
||||||
|
team, project, organization, person or key — all of them, `jira.*`, or some
|
||||||
|
by name — beside whatever else that level is granted, and saying when a
|
||||||
|
level above keeps them out of reach; **Revoke** a grant;
|
||||||
|
**Change…** its address, timeout or rate; **Replace its credential…**;
|
||||||
|
**Turn off** (its tools are listed nowhere and every call refused) or on;
|
||||||
|
**Remove…**. A server's tools are seen nowhere until granted. The screen
|
||||||
|
also says how a person points an agent at the gateway, with a key of their
|
||||||
|
own from `/profile/keys`. `llmgw ctl mcp` does the same from a shell, and
|
||||||
|
the web page **MCP servers** (`/admin/mcp`, administrators only) in a browser.
|
||||||
|
|
||||||
**Mirror** is what the model mirror holds, one row for each version of each
|
**Mirror** is what the model mirror holds, one row for each version of each
|
||||||
model — a file, or the set of files a model of several is: shards, a vision
|
model — a file, or the set of files a model of several is: shards, a vision
|
||||||
projector, a config and tokenizer — with how far its download has got and
|
projector, a config and tokenizer — with how far its download has got and
|
||||||
@@ -246,6 +263,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 +374,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,47 @@ 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 as the model will, with what `redact`
|
||||||
|
took out still out; on output the whole answer, once, at its end, as the
|
||||||
|
client reads it — with `restore`, the values put back. Being at the end, a
|
||||||
|
service can stop a streamed answer only where it is held back
|
||||||
|
(`stream: buffer_all`): released a sentence at a time, the client has read
|
||||||
|
it by then, and what the service finds ends the stream with
|
||||||
|
`content_filter`, flags it and is recorded. The rule recorded is
|
||||||
|
`service:<name>:<reason>`. The service is told the text only, never who
|
||||||
|
sent it. Under `fail: open`, the default, a service that cannot be asked
|
||||||
|
lets the text through — and a caller can make it fail, sending what it
|
||||||
|
refuses to read; give `fail: closed` where the service must decide. Of an
|
||||||
|
answer asked for with `n` above 1, the service reads the first.
|
||||||
|
|
||||||
## Where a policy applies
|
## 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
|
||||||
|
|||||||
+678
-32
@@ -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.
|
||||||
|
|
||||||
@@ -331,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.
|
||||||
@@ -639,6 +703,8 @@ Nothing to do: the agent installs them once the node joins, under
|
|||||||
| vLLM | nodes with an NVIDIA GPU it can run on | a Python environment built by uv from PyPI, about 8 GB |
|
| vLLM | nodes with an NVIDIA GPU it can run on | a Python environment built by uv from PyPI, about 8 GB |
|
||||||
| SGLang | only when you ask for it | the same way as vLLM |
|
| SGLang | only when you ask for it | the same way as vLLM |
|
||||||
| TensorRT-LLM | only nodes where you installed it | not installed by llmgw: see [TensorRT-LLM, which you bring](#tensorrt-llm-which-you-bring) |
|
| TensorRT-LLM | only nodes where you installed it | not installed by llmgw: see [TensorRT-LLM, which you bring](#tensorrt-llm-which-you-bring) |
|
||||||
|
| FreeToken | nodes with a GPU of compute capability 8.0 or newer (RTX 30, A100 and later), driver 580+, x86-64 and the CUDA 13 toolkit | a Python environment built by uv from PyPI (`freetoken[accel]`); see [FreeToken](#freetoken-models-larger-than-the-card) |
|
||||||
|
| Strata | only when you ask for it (`engines: {strata: "0.2.7"}`) | its source, pinned by digest, in a Python environment, and its prebuilt engine for CUDA 13 (driver 580+) or CUDA 12.9 (Pascal to Ada, driver 575+); see [Strata](#strata-a-large-model-of-experts-on-one-card) |
|
||||||
|
|
||||||
vLLM is resolved as PyPI stood a day after that vLLM was published, so every
|
vLLM is resolved as PyPI stood a day after that vLLM was published, so every
|
||||||
node gets the same dependencies whenever it is built, and installed with
|
node gets the same dependencies whenever it is built, and installed with
|
||||||
@@ -681,6 +747,100 @@ you installed yourself on a node, name its path in that node's `agent.yaml`
|
|||||||
(`engines.llamacpp.bin`, `engines.vllm.bin`, `engines.sglang.python`,
|
(`engines.llamacpp.bin`, `engines.vllm.bin`, `engines.sglang.python`,
|
||||||
`engines.trtllm.python`): the agent then installs none of its own beside it.
|
`engines.trtllm.python`): the agent then installs none of its own beside it.
|
||||||
|
|
||||||
|
#### Strata: a large model of experts on one card
|
||||||
|
|
||||||
|
Strata runs a few mixture-of-experts models — Qwen3.8-Flash-Next (125B) and its
|
||||||
|
Swift, Coder, Unsloth, Gyro and NVFP4 versions, Qwen3.6-35B-A3B, DeepSeek
|
||||||
|
V4.1-Flash — with the experts in the node's RAM and the rest on one card of 12
|
||||||
|
GB or more. Where it is installed, the plan offers it for a repository of one
|
||||||
|
of those families:
|
||||||
|
|
||||||
|
```
|
||||||
|
llmgw ctl model plan ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF
|
||||||
|
llmgw ctl model serve ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF \
|
||||||
|
--file Qwen3.8-Flash-Next-GSQ-RCO-IQ2_XS-00001-of-00002.gguf --engine strata
|
||||||
|
```
|
||||||
|
|
||||||
|
The Hugging Face explorer shows the same ("1× … with Strata, N GB of RAM"),
|
||||||
|
and the mirror's serve form serves such a version on Strata.
|
||||||
|
|
||||||
|
The mirror page's **Strata** tab lists every family and size Strata runs, with
|
||||||
|
what each downloads and keeps in RAM and where it would run here:
|
||||||
|
|
||||||
|
- **Fetch into the mirror**, for the GGUF families: the size's files, checked
|
||||||
|
against Hugging Face's checksums; the version's serve form then serves it on
|
||||||
|
Strata.
|
||||||
|
- **Serve**, for the packs Strata fetches itself — the NVFP4 packs
|
||||||
|
(`Maximilian228/Qwen3.8-Flash-Next-NVFP4-GPTQ-Strata` and its NVIDIA
|
||||||
|
sibling, NVIDIA's FP4-Local) and DeepSeek V4.1: the model is added with the
|
||||||
|
repository as its source, and its first start fetches tens of gigabytes onto
|
||||||
|
the node, each file pinned and checked by Strata, before preparing it.
|
||||||
|
`ctl model serve` does the same for these repositories.
|
||||||
|
|
||||||
|
Repositories of architecture `strata-ple` that Strata 0.2.7 does not list —
|
||||||
|
community packs such as `…-Q8_0-Strata` — are named as such in the explorer:
|
||||||
|
Strata's setup refuses them, and llama.cpp cannot read them. A Strata profile
|
||||||
|
says its family and size (`--family qwen --size IQ2_XS`), which the catalog
|
||||||
|
checks against what Strata runs, and reserves the RAM the size keeps its experts
|
||||||
|
in (48 GB and the engine's 4 for IQ2_XS) unless it says `min_memory_gb`. Its
|
||||||
|
first start prepares the model on the node, which takes minutes, and fetches a
|
||||||
|
draft layer of about 5 GB from the internet.
|
||||||
|
|
||||||
|
#### FreeToken: models larger than the card
|
||||||
|
|
||||||
|
[FreeToken](https://github.com/FlashML-org/FreeToken) serves mixture-of-experts
|
||||||
|
models several times a card's size from one card: the experts stay in the
|
||||||
|
node's RAM, the ones used most are cached in VRAM, and the rest stream over
|
||||||
|
PCIe. Unlike Strata it reads Hugging Face safetensors checkpoints as they are
|
||||||
|
published — DeepSeek V4 Flash, GLM-5.x, Qwen3.6/3.8 (Flash-Next among them),
|
||||||
|
gpt-oss, Gemma 4, MiniMax M2.5/M3 — in FP8, NVFP4, MXFP4 or BF16, and batches
|
||||||
|
several requests (`--max-running-requests`, 4 by default).
|
||||||
|
|
||||||
|
Every node that can run it installs it, as with vLLM: an NVIDIA GPU of compute
|
||||||
|
capability 8.0 or newer, driver 580 or newer, x86-64. Older cards are passed
|
||||||
|
over without a word — on a Quadro RTX 6000 (7.5) version 0.2.1 had no kernel
|
||||||
|
image for the card's first prefill, and in fp16 its first request ended in an
|
||||||
|
illegal memory access. Its FP8 checkpoints need 8.9 (Ada, Hopper) and its NVFP4
|
||||||
|
ones 12.0 (RTX 50, RTX PRO 6000 Blackwell). It compiles some kernels the first
|
||||||
|
time a model loads, so the node also needs the **CUDA 13 toolkit** (`nvcc` in
|
||||||
|
`CUDA_HOME`, `/usr/local/cuda` or `/usr/local/cuda-13.*`); a node of new cards
|
||||||
|
without one says so on the cluster page instead of installing an engine that
|
||||||
|
fails on its first model.
|
||||||
|
|
||||||
|
A FreeToken profile runs on one card of one node (`llmgw ctl model add -f`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: glm-5.2
|
||||||
|
source: /var/lib/llmgw-weights/GLM-5.2-NVFP4 # a safetensors checkpoint's directory
|
||||||
|
profiles:
|
||||||
|
- name: one-card
|
||||||
|
engine: freetoken
|
||||||
|
gpu_class: rtx-pro-6000-96g
|
||||||
|
min_memory_gb: 200 # the experts live in RAM: say how much
|
||||||
|
args: ["--max-running-requests", "8"]
|
||||||
|
```
|
||||||
|
|
||||||
|
A Hugging Face id as the source works too — FreeToken downloads the
|
||||||
|
repository itself — but it is not pinned to a commit or checked against
|
||||||
|
Hugging Face's checksums as vLLM's are, FreeToken having no `--revision`; a
|
||||||
|
directory the mirror fetched, or one put on the node, is.
|
||||||
|
|
||||||
|
The agent gives it the model, its address, its card (`--gpu`) and the key (in
|
||||||
|
`FREETOKEN_API_KEY`, not on the command line); `--mmproj`, media paths and
|
||||||
|
domains, `--hf-overrides` and `--moe-cache-split` are refused as they read
|
||||||
|
files or fetch. `ctl model context` sets `--max-seq-len-override`. Readiness
|
||||||
|
waits for its `/health` to stop saying `loading`, since it answers 200 from the
|
||||||
|
moment its HTTP server is up. It serves no Prometheus metrics, so the router
|
||||||
|
counts its requests itself.
|
||||||
|
|
||||||
|
llmgw follows FreeToken release by release: the version is
|
||||||
|
`DefaultEngines["freetoken"]`, and a weekly workflow
|
||||||
|
(`.gitea/workflows/engine-releases.yml`) fails when PyPI has a newer one, the
|
||||||
|
notice to check it and move to it. A version of your own is named under
|
||||||
|
`engines` like any other (`freetoken: "0.2.2"`), and `freetoken: ""` installs
|
||||||
|
none; one node is turned off with its build set to *none* (console, the node's
|
||||||
|
menu).
|
||||||
|
|
||||||
#### Choosing a node's vLLM build
|
#### Choosing a node's vLLM build
|
||||||
|
|
||||||
Which build a GPU node installs is chosen on the cluster page, in the **vLLM
|
Which build a GPU node installs is chosen on the cluster page, in the **vLLM
|
||||||
@@ -812,6 +972,45 @@ Nothing to do here if you built the mirror in step 3 — the agent fetches what
|
|||||||
model's `source` names, and that source is your mirror. This section is for the
|
model's `source` names, and that source is your mirror. This section is for the
|
||||||
arrangements where the files are already on the machine.
|
arrangements where the files are already on the machine.
|
||||||
|
|
||||||
|
**Weights in a pickle format are refused.** `.bin`, `.pt`, `.pth`, `.ckpt` and
|
||||||
|
`.pkl` are Python pickles, and loading one runs whatever it holds, as the
|
||||||
|
engine's user, on the node — which is how the malicious models found on Hugging
|
||||||
|
Face work. A model whose source is one, or a mirror version holding one, is
|
||||||
|
refused when it is added and by the node before it fetches anything; vLLM and
|
||||||
|
SGLang given a model by its Hugging Face id read only its safetensors. Take the
|
||||||
|
repository's safetensors or GGUF. A model that has no other form, and whose
|
||||||
|
files were checked, can say so:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: triposr
|
||||||
|
source: http://mirror:8081/triposr/<rev>/model.ckpt
|
||||||
|
allow_pickle: true
|
||||||
|
```
|
||||||
|
|
||||||
|
A local path is judged by its name as any source is; a local directory is
|
||||||
|
not looked into. What an engine downloads itself by a Hugging Face id, past
|
||||||
|
the node — an adapter given by its id, a TensorRT-LLM model's — is refused
|
||||||
|
when the model is added, unless it says `allow_pickle`: mirror its
|
||||||
|
safetensors and give that instead.
|
||||||
|
|
||||||
|
**Weights are pinned to a commit and checked.** A model added with
|
||||||
|
`ctl model add`, the API or the console, whose weights come from Hugging Face,
|
||||||
|
is pinned to the commit the repository is at when it is added:
|
||||||
|
|
||||||
|
- `hf:org/repo/file` and a `…/resolve/main/…` URL become that commit's URL;
|
||||||
|
- a repository vLLM or SGLang downloads itself is given `--revision`;
|
||||||
|
- the sha256 Hugging Face published for each file goes in the model's origin
|
||||||
|
(its definition, `GET /api/v1/models/NAME`, shows it).
|
||||||
|
|
||||||
|
A node then refuses a copy of the file that is not it, naming both checksums,
|
||||||
|
and deletes it; a cached copy is checked once and its checksum kept beside it.
|
||||||
|
The mirror does the same for a model `ctl model serve` fetches, and
|
||||||
|
`llmgw mirror add` for a Hugging Face URL (`--sha256` for any other). A new
|
||||||
|
upload to the repository reaches the cluster only through `ctl model update`,
|
||||||
|
which says what changed. Not pinned: models declared in the cluster's settings,
|
||||||
|
which are not added this way, and a vLLM model whose arguments already say
|
||||||
|
`--revision` keeps that one.
|
||||||
|
|
||||||
Put them where the node can read them. `scp`, a shared filesystem, or the
|
Put them where the node can read them. `scp`, a shared filesystem, or the
|
||||||
engine's own downloader are all valid, and which you want depends on how many
|
engine's own downloader are all valid, and which you want depends on how many
|
||||||
nodes you have.
|
nodes you have.
|
||||||
@@ -970,6 +1169,27 @@ A node with no location is placed as before, as a rack of its own.
|
|||||||
See [facility-aware-placement.md](facility-aware-placement.md) for power caps
|
See [facility-aware-placement.md](facility-aware-placement.md) for power caps
|
||||||
and holds.
|
and holds.
|
||||||
|
|
||||||
|
### A card in the wrong slot
|
||||||
|
|
||||||
|
An x16 card in a slot wired x8 or x4, behind a riser, or in a slot of an
|
||||||
|
older PCIe generation than the card's, links slower than it was built to and
|
||||||
|
works otherwise. A model on the card still decodes at the card's own memory
|
||||||
|
speed, but it loads slower, a replica split over several cards by PCIe is
|
||||||
|
slower, and so is anything that spills to the host. Each card's agent reads
|
||||||
|
how its link stands — the fastest generation card and slot allow together,
|
||||||
|
and the lanes in use — against what the card could do, and the node says so
|
||||||
|
among its warnings (the dashboard, the console's Nodes, `llmgw doctor`):
|
||||||
|
|
||||||
|
```
|
||||||
|
GPU 1 (NVIDIA H100 80GB HBM3) linked at x8 of its x16 lanes: 31.5 GB/s where it
|
||||||
|
could have 63.0. Check the slot (wired fewer lanes, or an older generation), a
|
||||||
|
riser, or the BIOS's PCIe setting
|
||||||
|
```
|
||||||
|
|
||||||
|
The generation a card reports now drops while it idles, to save power; what is
|
||||||
|
compared is the fastest it may reach, which does not. `lspci -vv -s BUS-ID`
|
||||||
|
shows the same: `LnkCap` is the card's, `LnkSta` the link's.
|
||||||
|
|
||||||
## 6. Setting the node's CPU model, if it is a VM
|
## 6. Setting the node's CPU model, if it is a VM
|
||||||
|
|
||||||
Proxmox defaults a VM to `kvm64`, which hides AVX, AVX2, FMA and even SSE4.2
|
Proxmox defaults a VM to `kvm64`, which hides AVX, AVX2, FMA and even SSE4.2
|
||||||
@@ -1287,6 +1507,26 @@ 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`.
|
||||||
@@ -1503,6 +1743,93 @@ Not yet: models declared in server.yaml cannot name a peer's model as a
|
|||||||
fallback (the catalog in the file is checked before the peers are read), and
|
fallback (the catalog in the file is checked before the peers are read), and
|
||||||
a peer behind a private CA needs that CA in the system's trust store.
|
a peer behind a private CA needs that CA in the system's trust store.
|
||||||
|
|
||||||
|
### Stopping a model, and removing one with its files
|
||||||
|
|
||||||
|
```
|
||||||
|
llmgw ctl model stop qwen-7b # none of its replicas run; it is kept
|
||||||
|
llmgw ctl model start qwen-7b # runs again, scaled as it was
|
||||||
|
llmgw ctl model remove qwen-7b # leaves the catalog; its files stay
|
||||||
|
llmgw ctl model remove qwen-7b --purge # its files on the mirror and the nodes too
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Stop** keeps the model in the catalog with its files and what it was
|
||||||
|
scaled to; a request for it is told an administrator stopped it. `ctl
|
||||||
|
models` shows it as stopped. A `ctl scale` while it is stopped starts it at
|
||||||
|
that number.
|
||||||
|
- **Remove** leaves its files on the mirror and the nodes, for adding it
|
||||||
|
again without fetching it. `ctl weights` lists such files on the nodes.
|
||||||
|
- **Remove --purge** deletes every version of it on the mirror (a second
|
||||||
|
mirror follows), its adapters and the weights its last update replaced,
|
||||||
|
and the nodes' copies once its replicas have stopped. What another model
|
||||||
|
uses — a canary's weights, a shared adapter — stays. It cannot be undone:
|
||||||
|
adding it again fetches it again.
|
||||||
|
|
||||||
|
The console has the same in a model's menu: Stop… or Start, Remove…, and
|
||||||
|
Remove with its files….
|
||||||
|
|
||||||
|
### Changing a served model's context and longest answer
|
||||||
|
|
||||||
|
How much one request may use, and how long an answer may be, are set when a
|
||||||
|
model is served. To change them afterwards, in place:
|
||||||
|
|
||||||
|
```
|
||||||
|
llmgw ctl model context qwen-7b 32768 # tokens a request
|
||||||
|
llmgw ctl model context qwen-7b 32768 --slots 4 # llama.cpp: four at once
|
||||||
|
llmgw ctl model context qwen-7b 32768 --max-output 8192 # and the longest answer
|
||||||
|
llmgw ctl model context qwen-7b --max-output 4096 # the longest answer alone
|
||||||
|
```
|
||||||
|
|
||||||
|
The longest answer is the gateway's: a request asking for more `max_tokens`
|
||||||
|
is refused, `/v1/models` lists it as `max_output_tokens` for clients to read,
|
||||||
|
and the account page's chat asks for no more. Changed alone it is only
|
||||||
|
the catalog's, in force at once, and no replica is started again. With a
|
||||||
|
context it is set with it; left out, it grows and shrinks with the context
|
||||||
|
as before, unless it was set to something else. It never exceeds the context.
|
||||||
|
|
||||||
|
For a context, the catalog, the engine's arguments (`--ctx-size` and
|
||||||
|
`--parallel` for llama.cpp, `--max-model-len` for vLLM, `--context-length` for
|
||||||
|
SGLang, `--context` for Strata's setup, `--max-seq-len-override` for
|
||||||
|
FreeToken) and the memory its KV cache takes change together. Strata and
|
||||||
|
FreeToken size their caches to the card themselves, so their cards are not
|
||||||
|
checked here. A context longer than the
|
||||||
|
model was trained for is refused, and so is one its cards cannot hold — with
|
||||||
|
the figures, so you know how far to go. Its replicas are then started again on
|
||||||
|
it, one by one, a new one ready before the old one stops. The console has the
|
||||||
|
same under a model's menu, Context length…, with the longest answer beside
|
||||||
|
it. The admin API is `POST /api/v1/models/{name}/context` with `tokens`,
|
||||||
|
`slots` and `max_output`, any of them left out. A model declared in the cluster's
|
||||||
|
settings is changed there (`llmgw ctl config edit`).
|
||||||
|
|
||||||
|
### Moving a served model to more cards
|
||||||
|
|
||||||
|
How many cards of one node each replica uses can be changed while the model
|
||||||
|
is served:
|
||||||
|
|
||||||
|
```
|
||||||
|
llmgw ctl model gpus qwen3-coder-30b 2 # two cards a replica
|
||||||
|
llmgw ctl model gpus llama-70b 4 --profile h100 # the model has several profiles on cards
|
||||||
|
llmgw ctl model gpus qwen3-coder-30b 2 --in-place # no room beside the running replica
|
||||||
|
```
|
||||||
|
|
||||||
|
vLLM, SGLang and TensorRT-LLM spread the model by tensor parallel (`tp`),
|
||||||
|
which needs the count to divide the model's KV heads or be divided by them:
|
||||||
|
a model with 8 splits 2, 4, 8 or 16 ways, not 3 or 12. llama.cpp has no
|
||||||
|
tensor parallel; it splits the layers over the cards (`gpus_per_node`), and
|
||||||
|
a `--tensor-split` written for the old cards is dropped. ComfyUI and Strata
|
||||||
|
run on one card. A count no node has is refused, and so is one whose cards
|
||||||
|
would not hold the weights and the context — with the figures; fewer cards
|
||||||
|
with a long context may need `llmgw ctl model context` first.
|
||||||
|
|
||||||
|
Its replicas are then started again on it, each new one ready beside the old
|
||||||
|
before the old stops. That needs the cards free beside it: on a node of two
|
||||||
|
cards with the model on one, two do not fit until it is gone, and the command
|
||||||
|
says so. `--in-place` stops each replica first and starts its replacement on
|
||||||
|
the cards it held and the free ones, and the model does not answer while the
|
||||||
|
new one loads — a model with several replicas keeps answering on the others.
|
||||||
|
A replica spread over several nodes is laid out by hand, with a fabric
|
||||||
|
(`nodes`, `gpus_per_node` in the profile). The console has the same under a
|
||||||
|
model's menu, GPUs….
|
||||||
|
|
||||||
### Trying new weights on some of the traffic first
|
### Trying new weights on some of the traffic first
|
||||||
|
|
||||||
When a model's repository publishes new weights, `llmgw ctl model update`
|
When a model's repository publishes new weights, `llmgw ctl model update`
|
||||||
@@ -1762,12 +2089,31 @@ dashboard, is in [deploy/monitoring/](../deploy/monitoring/) — including where
|
|||||||
to run it, which is not the control plane: monitoring that dies with the thing
|
to run it, which is not the control plane: monitoring that dies with the thing
|
||||||
it monitors tells you nothing at the moment you need it.
|
it monitors tells you nothing at the moment you need it.
|
||||||
|
|
||||||
Two figures repay knowing about. `llmgw_gpu_utilization_percent` is absent
|
Three figures repay knowing about. `llmgw_gpu_utilization_percent` is absent
|
||||||
rather than zero when the driver does not report it — a flat zero across a
|
rather than zero when the driver does not report it — a flat zero across a
|
||||||
fleet means the measurement is missing, not that the fleet is idle. And
|
fleet means the measurement is missing, not that the fleet is idle. And
|
||||||
`llmgw_gpu_nvlink_peers` at zero everywhere means the topology is unknown,
|
`llmgw_gpu_nvlink_peers` at zero everywhere means the topology is unknown,
|
||||||
which is what a binary built without the nvml tag reports.
|
which is what a binary built without the nvml tag reports.
|
||||||
|
|
||||||
|
The third is what "busy" means. `llmgw_gpu_utilization_percent` is
|
||||||
|
nvidia-smi's `utilization.gpu`: the share of time *any* kernel ran, however
|
||||||
|
little of the chip it ran on — a kernel on one SM of 142 reads 100%. A Quadro
|
||||||
|
RTX 6000 decoding read 92% busy while its SMs had work 69% of the time and
|
||||||
|
issued an instruction on 18% of their cycles. How much of the chip is at work
|
||||||
|
is in `llmgw_gpu_sm_active_percent`, `llmgw_gpu_sm_occupancy_percent`,
|
||||||
|
`llmgw_gpu_tensor_active_percent` and `llmgw_gpu_dram_active_percent`, from
|
||||||
|
the card's profiling counters. The agent reads them from NVML's GPM on
|
||||||
|
Hopper and later, with nothing to install; on older cards it reads them from
|
||||||
|
a [dcgm-exporter](https://github.com/NVIDIA/dcgm-exporter) on the node, at
|
||||||
|
`http://127.0.0.1:9400/metrics` unless `discovery.dcgm_exporter` in
|
||||||
|
agent.yaml says otherwise (`off` reads none). Where neither is there, the
|
||||||
|
dashboard's GPU map and the console colour cards by their draw against the
|
||||||
|
power limit (`llmgw_gpu_power_watts / llmgw_gpu_power_limit_watts`), which
|
||||||
|
rises with how much of the chip works rather than with whether anything
|
||||||
|
does. Low tensor-core activity in decode is not waste: a quantised
|
||||||
|
matrix-vector product at batch one runs on the integer units, and it is
|
||||||
|
prefill and batches that use the tensor cores.
|
||||||
|
|
||||||
### Token by token, and SLOs
|
### Token by token, and SLOs
|
||||||
|
|
||||||
Beside time to the first token, every streamed answer is timed per token:
|
Beside time to the first token, every streamed answer is timed per token:
|
||||||
@@ -1877,6 +2223,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
|
||||||
@@ -2041,8 +2399,10 @@ llmgw keys cache --project Acme/ml/train --off
|
|||||||
- **Policies still apply:** the limits are checked and the guardrails judge
|
- **Policies still apply:** the limits are checked and the guardrails judge
|
||||||
the request before the cache is looked in, and the output policy judges a
|
the request before the cache is looked in, and the output policy judges a
|
||||||
reused answer as it would a new one. A request continuing a stored
|
reused answer as it would a new one. A request continuing a stored
|
||||||
conversation, or under a `no_store` policy, is never answered from the
|
conversation, under a `no_store` policy, or whose redacted values are put
|
||||||
cache nor kept in it.
|
back in its answer (`restore: true`) is never answered from the cache nor
|
||||||
|
kept in it: redacted, two callers' requests read the same, and the answer
|
||||||
|
has the first caller's values in it.
|
||||||
- **What a hit costs:** nothing. It is recorded as a request — status 200,
|
- **What a hit costs:** nothing. It is recorded as a request — status 200,
|
||||||
the model, the key, the rate limit's count — with no tokens and no cost,
|
the model, the key, the rate limit's count — with no tokens and no cost,
|
||||||
marked as a cache hit (`llmgw ctl request <id>` says so), so it spends no
|
marked as a cache hit (`llmgw ctl request <id>` says so), so it spends no
|
||||||
@@ -2062,8 +2422,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, after a tool's result,
|
||||||
|
or with a picture, a sound or a file in it, 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
|
||||||
@@ -2172,6 +2570,7 @@ triposr`. What that adds is this entry, and its workflow is built in:
|
|||||||
```yaml
|
```yaml
|
||||||
- name: triposr
|
- name: triposr
|
||||||
source: hf:stabilityai/TripoSR/model.ckpt
|
source: hf:stabilityai/TripoSR/model.ckpt
|
||||||
|
allow_pickle: true # published as this checkpoint only
|
||||||
comfyui:
|
comfyui:
|
||||||
workflow: triposr # built in; a model of your own names a JSON file on every control plane
|
workflow: triposr # built in; a model of your own names a JSON file on every control plane
|
||||||
output: meshes
|
output: meshes
|
||||||
@@ -2319,6 +2718,62 @@ 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.
|
||||||
|
|
||||||
|
**A second mirror.** The mirror is on the first machine, and without a
|
||||||
|
second one, models the nodes already have keep serving while that machine
|
||||||
|
is down, and new ones wait for it. With a second, the cluster carries on.
|
||||||
|
- **Setting it up:** the screens offer one on either database machine
|
||||||
|
("A second mirror"); by command, `--second-mirror 1` (or `2`). For an
|
||||||
|
install that has one mirror, run this on the mirror's machine:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo llmgw mirror add-peer ubuntu@10.0.0.7 --control-plane 10.0.0.5 --control-plane 10.0.0.6 --control-plane 10.0.0.7
|
||||||
|
```
|
||||||
|
|
||||||
|
It logs in with root's ssh key on the mirror's machine, so that key must
|
||||||
|
be accepted for the user named on the second machine. When the mirror has
|
||||||
|
a machine of its own, without the control plane, the command cannot change
|
||||||
|
the cluster's settings itself: it prints the `llmgw ctl config set
|
||||||
|
mirror.peers …` to run on a control plane.
|
||||||
|
- **Kept in step:** each mirror reads the other every thirty seconds and
|
||||||
|
copies what it lacks, keeping a copy only if its checksum matches.
|
||||||
|
- A model removed from one is removed from the other, even if that one
|
||||||
|
was down at the time and comes back later.
|
||||||
|
- A model added again after it was removed stands on both.
|
||||||
|
- Neither mirror leads.
|
||||||
|
- **Control planes:** they name both (`mirror.peers` in the cluster's
|
||||||
|
settings) and send the mirror's management calls to the first that
|
||||||
|
answers.
|
||||||
|
- **Nodes:** while the mirror a model's address names is down, a node is
|
||||||
|
given the same file on the other.
|
||||||
|
- **Disk:** the second machine needs room for every model, as the first
|
||||||
|
does.
|
||||||
|
|
||||||
## 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
|
||||||
@@ -2335,8 +2790,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
|
||||||
@@ -2362,9 +2821,16 @@ Without it, put a load balancer in front that checks `/ready` — not
|
|||||||
`server add` checks, and says what to add to the mirror's unit if it
|
`server add` checks, and says what to add to the mirror's unit if it
|
||||||
refuses the new control plane — which would otherwise find out the first
|
refuses the new control plane — which would otherwise find out the first
|
||||||
time it leads and fetches a model.
|
time it leads and fetches a model.
|
||||||
- **Limits are per process.** Rate limits are counted in memory, and clients
|
- **Standbys can answer clients too.** With `router.active_active: true`
|
||||||
reach the leader through the floating address, so a failover starts each
|
(`llmgw ctl config set router.active_active true`) a standby answers `/v1`
|
||||||
key's minute afresh.
|
itself, routing to the leader's replicas, instead of passing it on: more
|
||||||
|
machines carry the traffic. Point clients at every control plane — DNS,
|
||||||
|
or your own load balancer checking `/ready` — since the floating address
|
||||||
|
stays on the leader. Rate limits stay the cluster's: each router's counts
|
||||||
|
reach the others every two seconds, and a standby that becomes the leader
|
||||||
|
keeps the minute it was counting. Without the setting, rate limits are
|
||||||
|
counted in the leader's memory and a failover starts each key's minute
|
||||||
|
afresh.
|
||||||
|
|
||||||
[deploy/ha/](../deploy/ha/) describes what each half does, for anyone putting
|
[deploy/ha/](../deploy/ha/) describes what each half does, for anyone putting
|
||||||
their own load balancer in front.
|
their own load balancer in front.
|
||||||
@@ -2397,6 +2863,89 @@ 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: a tool is seen where
|
||||||
|
every level above that names tools names it too. A team under an
|
||||||
|
organization that names `hr.*` sees no `jira.*` whatever the team is
|
||||||
|
given, until the organization names it as well.
|
||||||
|
- Unlike models, **none granted anywhere is none**: a tool reaches into a
|
||||||
|
company system, so it is granted, never assumed.
|
||||||
|
- `keys limits --tools` writes a level's list whole, every server's
|
||||||
|
together; an empty list (`--tools ''`) closes the level to every tool.
|
||||||
|
The console's **MCP servers** screen and the `/admin/mcp` page grant
|
||||||
|
and take back one server's tools: a level given its first list starts
|
||||||
|
from the nearest list above it, so it keeps what it saw; one left
|
||||||
|
naming none goes back to naming none, or stays closed when a level
|
||||||
|
above names tools, so taking one server away opens it to no other. They
|
||||||
|
say when a level above keeps the granted tools out of reach.
|
||||||
|
- **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,
|
||||||
@@ -2404,8 +2953,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:
|
||||||
@@ -2454,6 +3005,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`,
|
||||||
@@ -3025,6 +3669,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
|
||||||
|
|||||||
+16
-14
@@ -3,9 +3,9 @@
|
|||||||
A knowledge base is a collection of a company's documents that the cluster
|
A knowledge base is a collection of a company's documents that the cluster
|
||||||
can answer from. People put their documents into it from their own machines
|
can answer from. People put their documents into it from their own machines
|
||||||
with `llmgw-kb`. The tool reads the files on that machine, cuts them into
|
with `llmgw-kb`. The tool reads the files on that machine, cuts them into
|
||||||
passages ("chunks"), has the gateway embed each passage with the person's own
|
passages ("chunks") and sends their text; the gateway embeds each passage
|
||||||
key, and sends the text and the vectors. The files themselves never leave
|
with the person's own key and keeps the text and the vectors. The files
|
||||||
the machine.
|
themselves never leave the machine.
|
||||||
|
|
||||||
## Why retrieval, and not training
|
## Why retrieval, and not training
|
||||||
|
|
||||||
@@ -159,10 +159,12 @@ llmgw ctl config set router.admission.bands '[{name: gold, priority: 300, satura
|
|||||||
llmgw keys create import --project Acme/docs --band bulk
|
llmgw keys create import --project Acme/docs --band bulk
|
||||||
```
|
```
|
||||||
|
|
||||||
The people's questions then go first, and the import waits behind them. A
|
The people's questions then go first, and the import waits behind them. The
|
||||||
request that waits as long as its band allows is answered 504 with
|
gateway embeds a document's passages 32 at a time, four batches at once,
|
||||||
`Retry-After`, and `llmgw-kb` sends it again rather than counting the
|
enough to fill an embedding replica's slots; a batch told to wait (`Retry-After`)
|
||||||
passage as failed. It gives up after 15 minutes.
|
waits up to five minutes before the document is answered the same, and
|
||||||
|
`llmgw-kb` sends it again rather than counting it as failed. It gives up
|
||||||
|
after 15 minutes.
|
||||||
|
|
||||||
## For everyone: `llmgw-kb`
|
## For everyone: `llmgw-kb`
|
||||||
|
|
||||||
@@ -225,6 +227,7 @@ not changed.
|
|||||||
| `--include`, `--exclude` | Glob patterns. `*.pdf` matches a file name; `reports/**/*.xlsx` matches a path. |
|
| `--include`, `--exclude` | Glob patterns. `*.pdf` matches a file name; `reports/**/*.xlsx` matches a path. |
|
||||||
| `--hidden` | Reads hidden files and folders too (left out by default). |
|
| `--hidden` | Reads hidden files and folders too (left out by default). |
|
||||||
| `-v` | Lists unchanged files too. |
|
| `-v` | Lists unchanged files too. |
|
||||||
|
| `--embed-here` | Embeds the passages from this machine and sends the vectors. With a gateway that does not embed them itself it does so anyway. |
|
||||||
|
|
||||||
Documents are named by their path under the folder, with forward slashes,
|
Documents are named by their path under the folder, with forward slashes,
|
||||||
on every system. With several folders, each folder's own name comes first.
|
on every system. With several folders, each folder's own name comes first.
|
||||||
@@ -259,13 +262,12 @@ scanned page with no OCR.
|
|||||||
|
|
||||||
### What leaves your machine
|
### What leaves your machine
|
||||||
|
|
||||||
For each new or changed document, two things leave the machine, both over
|
For each new or changed document, one request leaves the machine, over the
|
||||||
the gateway's TLS connection:
|
gateway's TLS connection: its name, its file's SHA-256 digest and size, and
|
||||||
|
its passages' text. The gateway embeds the passages through `/v1/embeddings`
|
||||||
- **The passages, to be embedded.** They go through `/v1/embeddings` like
|
as you, so your key's limits, budget and guardrails apply as if you had
|
||||||
any request of yours. Your key's limits, budget and guardrails apply.
|
asked. That is about a kilobyte a passage; embedding here (`--embed-here`)
|
||||||
- **The document itself.** Its name, its file's SHA-256 digest and size,
|
brings each vector down and sends it up again, some thirty times more.
|
||||||
and the passages with their vectors.
|
|
||||||
|
|
||||||
The file itself is never sent. `--dry-run` shows exactly what would be.
|
The file itself is never sent. `--dry-run` shows exactly what would be.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+30
-2
@@ -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`). |
|
||||||
@@ -146,7 +146,35 @@ agent is stopped.
|
|||||||
## 4. Add a model
|
## 4. Add a model
|
||||||
|
|
||||||
Open `/admin/mirror` and choose **Explore models**. It lists what is popular on
|
Open `/admin/mirror` and choose **Explore models**. It lists what is popular on
|
||||||
Hugging Face, showing only what your nodes can run.
|
Hugging Face, showing only what your nodes can run: judged by the cluster as it
|
||||||
|
is now — the largest card and its compute capability, the most memory on one
|
||||||
|
node — so a model too big for them, in a form those cards lack (FP8 needs 8.9),
|
||||||
|
or in neither GGUF nor safetensors is left out, and the page says how many.
|
||||||
|
Without GPUs it lists GGUF only. Untick **only what these nodes can run** to
|
||||||
|
see everything. On a model's page a file too big for the cluster is shown
|
||||||
|
without a **Fetch** button, with what it would need.
|
||||||
|
|
||||||
|
The **CPU tok/s** column is about how fast one reply would come on the CPU of
|
||||||
|
the fastest node whose memory holds the model at Q4_K_M; a model's page gives
|
||||||
|
it for each GGUF file by its own size. Every node's agent measures how fast its
|
||||||
|
memory reads when it starts (it is in the agent's log as `memory_gbs`), and a
|
||||||
|
token on a CPU reads every weight once: the estimate is that speed over the
|
||||||
|
model's size — for a model of experts named like `30B-A3B`, its active part.
|
||||||
|
On the test cluster it came within 15% of what llama.cpp measured. Several
|
||||||
|
replies at once share the reads and each is slower.
|
||||||
|
|
||||||
|
**GPU tok/s** is the same on the cards, for the layout this cluster would
|
||||||
|
give the model — the fewest cards that hold it, of each class, the fastest of
|
||||||
|
those; its tooltip says which ("2× h100-80g, NVLink, vLLM", "2 nodes × 1×
|
||||||
|
a100-80g over ib0 (InfiniBand 200 Gb/s), vLLM"). A card's memory bandwidth is
|
||||||
|
known from its model name (an H100 SXM and an H100 PCIe differ by half); a card
|
||||||
|
not in the table gets no figure. Tensor parallel across a node's cards divides
|
||||||
|
what each reads, and adds two all-reduces a layer, short over NVLink and
|
||||||
|
longer over PCIe; llama.cpp across cards splits by layer, so the cards take
|
||||||
|
turns and a reply is no faster than on one; a pipeline across nodes takes turns
|
||||||
|
too, and adds a hop over the fabric for each node. One card's figures were
|
||||||
|
measured on the test cluster's RTX 6000; tensor parallel and the fabric are
|
||||||
|
from what NCCL is known to take, not measured here.
|
||||||
|
|
||||||
1. Click a model to read its card.
|
1. Click a model to read its card.
|
||||||
2. Pick one of its files, usually a `q4_k_m` for a first try, and click
|
2. Pick one of its files, usually a `q4_k_m` for a first try, and click
|
||||||
|
|||||||
+400
-1
@@ -37,6 +37,7 @@ rather than repeated.
|
|||||||
| `default_tpm` | `int` | [Auth](#auth) |
|
| `default_tpm` | `int` | [Auth](#auth) |
|
||||||
| `signup_open` | `bool` | [Auth](#auth) |
|
| `signup_open` | `bool` | [Auth](#auth) |
|
||||||
| `oidc` | `OIDC` | [Auth](#auth) |
|
| `oidc` | `OIDC` | [Auth](#auth) |
|
||||||
|
| `ldap` | `LDAP` | [Auth](#auth) |
|
||||||
| `scim` | `SCIM` | [Auth](#auth) |
|
| `scim` | `SCIM` | [Auth](#auth) |
|
||||||
| `max_key_ttl` | `time.Duration` | [Auth](#auth) |
|
| `max_key_ttl` | `time.Duration` | [Auth](#auth) |
|
||||||
| `personal` | `Personal` | [Auth](#auth) |
|
| `personal` | `Personal` | [Auth](#auth) |
|
||||||
@@ -52,6 +53,7 @@ rather than repeated.
|
|||||||
| `retain_days` | `int` | [Batch](#batch) |
|
| `retain_days` | `int` | [Batch](#batch) |
|
||||||
| `max_bytes` | `int64` | [Cache](#cache) |
|
| `max_bytes` | `int64` | [Cache](#cache) |
|
||||||
| `max_entry_bytes` | `int64` | [Cache](#cache) |
|
| `max_entry_bytes` | `int64` | [Cache](#cache) |
|
||||||
|
| `embedding_model` | `string` | [Cache](#cache) |
|
||||||
| `model` | `string` | [Canary](#canary) |
|
| `model` | `string` | [Canary](#canary) |
|
||||||
| `percent` | `int` | [Canary](#canary) |
|
| `percent` | `int` | [Canary](#canary) |
|
||||||
| `min_requests` | `int` | [Canary](#canary) |
|
| `min_requests` | `int` | [Canary](#canary) |
|
||||||
@@ -105,10 +107,13 @@ rather than repeated.
|
|||||||
| `url` | `string` | [External](#external) |
|
| `url` | `string` | [External](#external) |
|
||||||
| `model` | `string` | [External](#external) |
|
| `model` | `string` | [External](#external) |
|
||||||
| `api_key_file` | `string` | [External](#external) |
|
| `api_key_file` | `string` | [External](#external) |
|
||||||
|
| `api_key_files` | `[]ExternalKey` | [External](#external) |
|
||||||
| `peer` | `string` | [External](#external) |
|
| `peer` | `string` | [External](#external) |
|
||||||
| `api` | `string` | [External](#external) |
|
| `api` | `string` | [External](#external) |
|
||||||
| `workflow` | `string` | [External](#external) |
|
| `workflow` | `string` | [External](#external) |
|
||||||
| `output` | `string` | [External](#external) |
|
| `output` | `string` | [External](#external) |
|
||||||
|
| `file` | `string` | [ExternalKey](#externalkey) |
|
||||||
|
| `weight` | `float64` | [ExternalKey](#externalkey) |
|
||||||
| `budgets` | `[]FacilityBudget` | [Facility](#facility) |
|
| `budgets` | `[]FacilityBudget` | [Facility](#facility) |
|
||||||
| `margin` | `float64` | [Facility](#facility) |
|
| `margin` | `float64` | [Facility](#facility) |
|
||||||
| `gpu_watts` | `float64` | [Facility](#facility) |
|
| `gpu_watts` | `float64` | [Facility](#facility) |
|
||||||
@@ -121,6 +126,7 @@ rather than repeated.
|
|||||||
| `name` | `string` | [Federation](#federation) |
|
| `name` | `string` | [Federation](#federation) |
|
||||||
| `interval` | `time.Duration` | [Federation](#federation) |
|
| `interval` | `time.Duration` | [Federation](#federation) |
|
||||||
| `enabled` | `bool` | [FirewallSettings](#firewallsettings) |
|
| `enabled` | `bool` | [FirewallSettings](#firewallsettings) |
|
||||||
|
| `binary` | `string` | [FreeTokenConfig](#freetokenconfig) |
|
||||||
| `min_compute_capability` | `float64` | [GPUNeed](#gpuneed) |
|
| `min_compute_capability` | `float64` | [GPUNeed](#gpuneed) |
|
||||||
| `min_memory_gb` | `int` | [GPUNeed](#gpuneed) |
|
| `min_memory_gb` | `int` | [GPUNeed](#gpuneed) |
|
||||||
| `format` | `string` | [GuardModel](#guardmodel) |
|
| `format` | `string` | [GuardModel](#guardmodel) |
|
||||||
@@ -144,6 +150,21 @@ rather than repeated.
|
|||||||
| `tokenize_timeout` | `time.Duration` | [KVIndex](#kvindex) |
|
| `tokenize_timeout` | `time.Duration` | [KVIndex](#kvindex) |
|
||||||
| `min_blocks` | `int` | [KVIndex](#kvindex) |
|
| `min_blocks` | `int` | [KVIndex](#kvindex) |
|
||||||
| `max_blocks` | `int` | [KVIndex](#kvindex) |
|
| `max_blocks` | `int` | [KVIndex](#kvindex) |
|
||||||
|
| `urls` | `[]string` | [LDAP](#ldap) |
|
||||||
|
| `start_tls` | `bool` | [LDAP](#ldap) |
|
||||||
|
| `ca_file` | `string` | [LDAP](#ldap) |
|
||||||
|
| `ca` | `string` | [LDAP](#ldap) |
|
||||||
|
| `bind_dn` | `string` | [LDAP](#ldap) |
|
||||||
|
| `bind_password_file` | `string` | [LDAP](#ldap) |
|
||||||
|
| `base_dn` | `string` | [LDAP](#ldap) |
|
||||||
|
| `group_base_dn` | `string` | [LDAP](#ldap) |
|
||||||
|
| `user_filter` | `string` | [LDAP](#ldap) |
|
||||||
|
| `label` | `string` | [LDAP](#ldap) |
|
||||||
|
| `create_accounts` | `bool` | [LDAP](#ldap) |
|
||||||
|
| `roles` | `map[string][]string` | [LDAP](#ldap) |
|
||||||
|
| `memberships` | `map[string]string` | [LDAP](#ldap) |
|
||||||
|
| `sync_interval` | `time.Duration` | [LDAP](#ldap) |
|
||||||
|
| `timeout` | `time.Duration` | [LDAP](#ldap) |
|
||||||
| `contact` | `string` | [Landing](#landing) |
|
| `contact` | `string` | [Landing](#landing) |
|
||||||
| `notice` | `string` | [Landing](#landing) |
|
| `notice` | `string` | [Landing](#landing) |
|
||||||
| `language` | `string` | [Landing](#landing) |
|
| `language` | `string` | [Landing](#landing) |
|
||||||
@@ -160,6 +181,7 @@ rather than repeated.
|
|||||||
| `parallel` | `int` | [LlamaCppConfig](#llamacppconfig) |
|
| `parallel` | `int` | [LlamaCppConfig](#llamacppconfig) |
|
||||||
| `threads` | `int` | [LlamaCppConfig](#llamacppconfig) |
|
| `threads` | `int` | [LlamaCppConfig](#llamacppconfig) |
|
||||||
| `api` | `string` | [Mirror](#mirror) |
|
| `api` | `string` | [Mirror](#mirror) |
|
||||||
|
| `peers` | `[]string` | [Mirror](#mirror) |
|
||||||
| `tls` | `tlsconf.Config` | [Mirror](#mirror) |
|
| `tls` | `tlsconf.Config` | [Mirror](#mirror) |
|
||||||
| `registry` | `string` | [Mirror](#mirror) |
|
| `registry` | `string` | [Mirror](#mirror) |
|
||||||
| `timeout` | `time.Duration` | [Mirror](#mirror) |
|
| `timeout` | `time.Duration` | [Mirror](#mirror) |
|
||||||
@@ -168,8 +190,10 @@ rather than repeated.
|
|||||||
| `aliases` | `[]string` | [Model](#model) |
|
| `aliases` | `[]string` | [Model](#model) |
|
||||||
| `description` | `string` | [Model](#model) |
|
| `description` | `string` | [Model](#model) |
|
||||||
| `source` | `string` | [Model](#model) |
|
| `source` | `string` | [Model](#model) |
|
||||||
|
| `allow_pickle` | `bool` | [Model](#model) |
|
||||||
| `profiles` | `[]Profile` | [Model](#model) |
|
| `profiles` | `[]Profile` | [Model](#model) |
|
||||||
| `scaling` | `Scaling` | [Model](#model) |
|
| `scaling` | `Scaling` | [Model](#model) |
|
||||||
|
| `stopped` | `*Scaling` | [Model](#model) |
|
||||||
| `class` | `string` | [Model](#model) |
|
| `class` | `string` | [Model](#model) |
|
||||||
| `owner` | `string` | [Model](#model) |
|
| `owner` | `string` | [Model](#model) |
|
||||||
| `external` | `*External` | [Model](#model) |
|
| `external` | `*External` | [Model](#model) |
|
||||||
@@ -211,6 +235,7 @@ rather than repeated.
|
|||||||
| `ib_root` | `string` | [Options](#options) |
|
| `ib_root` | `string` | [Options](#options) |
|
||||||
| `prefer_nvml` | `bool` | [Options](#options) |
|
| `prefer_nvml` | `bool` | [Options](#options) |
|
||||||
| `skip_gpus` | `bool` | [Options](#options) |
|
| `skip_gpus` | `bool` | [Options](#options) |
|
||||||
|
| `dcgm_exporter` | `string` | [Options](#options) |
|
||||||
| `repo` | `string` | [Origin](#origin) |
|
| `repo` | `string` | [Origin](#origin) |
|
||||||
| `revision` | `string` | [Origin](#origin) |
|
| `revision` | `string` | [Origin](#origin) |
|
||||||
| `files` | `[]OriginFile` | [Origin](#origin) |
|
| `files` | `[]OriginFile` | [Origin](#origin) |
|
||||||
@@ -238,6 +263,7 @@ rather than repeated.
|
|||||||
| `require_capabilities` | `bool` | [Policy](#policy) |
|
| `require_capabilities` | `bool` | [Policy](#policy) |
|
||||||
| `validate_structured` | `bool` | [Policy](#policy) |
|
| `validate_structured` | `bool` | [Policy](#policy) |
|
||||||
| `judge` | `*JudgePolicy` | [Policy](#policy) |
|
| `judge` | `*JudgePolicy` | [Policy](#policy) |
|
||||||
|
| `services` | `[]Service` | [Policy](#policy) |
|
||||||
| `stream` | `string` | [Policy](#policy) |
|
| `stream` | `string` | [Policy](#policy) |
|
||||||
| `enabled` | `bool` | [Prefix](#prefix) |
|
| `enabled` | `bool` | [Prefix](#prefix) |
|
||||||
| `max_nodes` | `int` | [Prefix](#prefix) |
|
| `max_nodes` | `int` | [Prefix](#prefix) |
|
||||||
@@ -269,6 +295,7 @@ rather than repeated.
|
|||||||
| `keep_within` | `string` | [Profile](#profile) |
|
| `keep_within` | `string` | [Profile](#profile) |
|
||||||
| `weight` | `float64` | [Profile](#profile) |
|
| `weight` | `float64` | [Profile](#profile) |
|
||||||
| `priority` | `int` | [Profile](#profile) |
|
| `priority` | `int` | [Profile](#profile) |
|
||||||
|
| `replicas` | `int` | [Profile](#profile) |
|
||||||
| `startup_timeout` | `time.Duration` | [Profile](#profile) |
|
| `startup_timeout` | `time.Duration` | [Profile](#profile) |
|
||||||
| `kv_events` | `bool` | [Profile](#profile) |
|
| `kv_events` | `bool` | [Profile](#profile) |
|
||||||
| `gpu` | `*GPUNeed` | [Profile](#profile) |
|
| `gpu` | `*GPUNeed` | [Profile](#profile) |
|
||||||
@@ -294,6 +321,7 @@ rather than repeated.
|
|||||||
| `batch` | `Batch` | [Router](#router) |
|
| `batch` | `Batch` | [Router](#router) |
|
||||||
| `cache` | `Cache` | [Router](#router) |
|
| `cache` | `Cache` | [Router](#router) |
|
||||||
| `trusted_proxies` | `[]string` | [Router](#router) |
|
| `trusted_proxies` | `[]string` | [Router](#router) |
|
||||||
|
| `active_active` | `bool` | [Router](#router) |
|
||||||
| `timeouts` | `Timeouts` | [Router](#router) |
|
| `timeouts` | `Timeouts` | [Router](#router) |
|
||||||
| `scoring` | `Scoring` | [Router](#router) |
|
| `scoring` | `Scoring` | [Router](#router) |
|
||||||
| `admission` | `Admission` | [Router](#router) |
|
| `admission` | `Admission` | [Router](#router) |
|
||||||
@@ -301,6 +329,7 @@ rather than repeated.
|
|||||||
| `injection` | `string` | [Rules](#rules) |
|
| `injection` | `string` | [Rules](#rules) |
|
||||||
| `deny` | `[]Pattern` | [Rules](#rules) |
|
| `deny` | `[]Pattern` | [Rules](#rules) |
|
||||||
| `max_chars` | `int` | [Rules](#rules) |
|
| `max_chars` | `int` | [Rules](#rules) |
|
||||||
|
| `restore` | `bool` | [Rules](#rules) |
|
||||||
| `token_file` | `string` | [SCIM](#scim) |
|
| `token_file` | `string` | [SCIM](#scim) |
|
||||||
| `python` | `string` | [SGLangConfig](#sglangconfig) |
|
| `python` | `string` | [SGLangConfig](#sglangconfig) |
|
||||||
| `ttft_p99` | `time.Duration` | [SLO](#slo) |
|
| `ttft_p99` | `time.Duration` | [SLO](#slo) |
|
||||||
@@ -361,8 +390,20 @@ rather than repeated.
|
|||||||
| `facility` | `Facility` | [Server](#server) |
|
| `facility` | `Facility` | [Server](#server) |
|
||||||
| `federation` | `Federation` | [Server](#server) |
|
| `federation` | `Federation` | [Server](#server) |
|
||||||
| `endpoints` | `[]Endpoint` | [Server](#server) |
|
| `endpoints` | `[]Endpoint` | [Server](#server) |
|
||||||
|
| `name` | `string` | [Service](#service) |
|
||||||
|
| `url` | `string` | [Service](#service) |
|
||||||
|
| `header_files` | `map[string]string` | [Service](#service) |
|
||||||
|
| `body` | `string` | [Service](#service) |
|
||||||
|
| `blocked` | `string` | [Service](#service) |
|
||||||
|
| `reason` | `string` | [Service](#service) |
|
||||||
|
| `on` | `string` | [Service](#service) |
|
||||||
|
| `action` | `string` | [Service](#service) |
|
||||||
|
| `timeout` | `time.Duration` | [Service](#service) |
|
||||||
| `stopped` | `bool` | [ServingSettings](#servingsettings) |
|
| `stopped` | `bool` | [ServingSettings](#servingsettings) |
|
||||||
| `bin` | `string` | [SimConfig](#simconfig) |
|
| `bin` | `string` | [SimConfig](#simconfig) |
|
||||||
|
| `dir` | `string` | [StrataConfig](#strataconfig) |
|
||||||
|
| `python` | `string` | [StrataConfig](#strataconfig) |
|
||||||
|
| `engine` | `string` | [StrataConfig](#strataconfig) |
|
||||||
| `python` | `string` | [TRTLLMConfig](#trtllmconfig) |
|
| `python` | `string` | [TRTLLMConfig](#trtllmconfig) |
|
||||||
| `store` | `string` | [Threads](#threads) |
|
| `store` | `string` | [Threads](#threads) |
|
||||||
| `retain_hours` | `int` | [Threads](#threads) |
|
| `retain_hours` | `int` | [Threads](#threads) |
|
||||||
@@ -394,6 +435,7 @@ rather than repeated.
|
|||||||
| `secret_file` | `string` | [Webhook](#webhook) |
|
| `secret_file` | `string` | [Webhook](#webhook) |
|
||||||
| `prompt_per_1k` | `int64` | [accounting.Price](#accountingprice) |
|
| `prompt_per_1k` | `int64` | [accounting.Price](#accountingprice) |
|
||||||
| `completion_per_1k` | `int64` | [accounting.Price](#accountingprice) |
|
| `completion_per_1k` | `int64` | [accounting.Price](#accountingprice) |
|
||||||
|
| `cached_per_1k` | `int64` | [accounting.Price](#accountingprice) |
|
||||||
| `per_image` | `int64` | [accounting.Price](#accountingprice) |
|
| `per_image` | `int64` | [accounting.Price](#accountingprice) |
|
||||||
| `per_mesh` | `int64` | [accounting.Price](#accountingprice) |
|
| `per_mesh` | `int64` | [accounting.Price](#accountingprice) |
|
||||||
| `name` | `string` | [admission.Band](#admissionband) |
|
| `name` | `string` | [admission.Band](#admissionband) |
|
||||||
@@ -432,6 +474,7 @@ rather than repeated.
|
|||||||
| `queue_ttl` | `time.Duration` | [config.Band](#configband) |
|
| `queue_ttl` | `time.Duration` | [config.Band](#configband) |
|
||||||
| `prompt_per_1k` | `int64` | [config.Price](#configprice) |
|
| `prompt_per_1k` | `int64` | [config.Price](#configprice) |
|
||||||
| `completion_per_1k` | `int64` | [config.Price](#configprice) |
|
| `completion_per_1k` | `int64` | [config.Price](#configprice) |
|
||||||
|
| `cached_per_1k` | `int64` | [config.Price](#configprice) |
|
||||||
| `per_image` | `int64` | [config.Price](#configprice) |
|
| `per_image` | `int64` | [config.Price](#configprice) |
|
||||||
| `per_mesh` | `int64` | [config.Price](#configprice) |
|
| `per_mesh` | `int64` | [config.Price](#configprice) |
|
||||||
| `disabled` | `[]string` | [engines.Config](#enginesconfig) |
|
| `disabled` | `[]string` | [engines.Config](#enginesconfig) |
|
||||||
@@ -441,6 +484,8 @@ rather than repeated.
|
|||||||
| `trtllm` | `TRTLLMConfig` | [engines.Config](#enginesconfig) |
|
| `trtllm` | `TRTLLMConfig` | [engines.Config](#enginesconfig) |
|
||||||
| `sim` | `SimConfig` | [engines.Config](#enginesconfig) |
|
| `sim` | `SimConfig` | [engines.Config](#enginesconfig) |
|
||||||
| `comfyui` | `ComfyUIConfig` | [engines.Config](#enginesconfig) |
|
| `comfyui` | `ComfyUIConfig` | [engines.Config](#enginesconfig) |
|
||||||
|
| `strata` | `StrataConfig` | [engines.Config](#enginesconfig) |
|
||||||
|
| `freetoken` | `FreeTokenConfig` | [engines.Config](#enginesconfig) |
|
||||||
| `enabled` | `*bool` | [guard.Config](#guardconfig) |
|
| `enabled` | `*bool` | [guard.Config](#guardconfig) |
|
||||||
| `default` | `[]string` | [guard.Config](#guardconfig) |
|
| `default` | `[]string` | [guard.Config](#guardconfig) |
|
||||||
| `policies` | `map[string]Policy` | [guard.Config](#guardconfig) |
|
| `policies` | `map[string]Policy` | [guard.Config](#guardconfig) |
|
||||||
@@ -616,6 +661,15 @@ OIDC is sign-in through an identity provider.
|
|||||||
|
|
||||||
Takes a [OIDC](#oidc) block.
|
Takes a [OIDC](#oidc) block.
|
||||||
|
|
||||||
|
### `ldap`
|
||||||
|
|
||||||
|
`LDAP`
|
||||||
|
|
||||||
|
LDAP is sign-in against a directory, Active Directory above all, and
|
||||||
|
the accounts kept in step with it.
|
||||||
|
|
||||||
|
Takes a [LDAP](#ldap) block.
|
||||||
|
|
||||||
### `scim`
|
### `scim`
|
||||||
|
|
||||||
`SCIM`
|
`SCIM`
|
||||||
@@ -728,6 +782,15 @@ Past it, the answers used longest ago go first.
|
|||||||
MaxEntryBytes bounds one answer; a longer one is not kept. 1 MB
|
MaxEntryBytes bounds one answer; a longer one is not kept. 1 MB
|
||||||
unless set.
|
unless set.
|
||||||
|
|
||||||
|
### `embedding_model`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
EmbeddingModel embeds the questions of projects that reuse answers to
|
||||||
|
similar ones (llmgw keys cache --similar): an embedding model in the
|
||||||
|
catalog. Unset, those projects reuse only the same request's answer.
|
||||||
|
The gateway asks it on its own account, as it asks a judge.
|
||||||
|
|
||||||
## Canary
|
## Canary
|
||||||
|
|
||||||
### `model`
|
### `model`
|
||||||
@@ -948,7 +1011,7 @@ work stands without being renewed.
|
|||||||
Only meaningful with a shared store, where a second control plane can
|
Only meaningful with a shared store, where a second control plane can
|
||||||
see the claim. Long enough to survive a slow query or a brief network
|
see the claim. Long enough to survive a slow query or a brief network
|
||||||
stall, short enough that a standby takes over before anyone has finished
|
stall, short enough that a standby takes over before anyone has finished
|
||||||
reading the alert. Zero takes the default.
|
reading the alert. Zero takes the default, 10s.
|
||||||
|
|
||||||
## CustomNode
|
## CustomNode
|
||||||
|
|
||||||
@@ -1103,6 +1166,15 @@ APIKeyFile holds the provider's key, on the control plane. A file
|
|||||||
rather than the key itself, so the key is in neither the catalog, the
|
rather than the key itself, so the key is in neither the catalog, the
|
||||||
store nor the audit log; empty for a provider that asks for none.
|
store nor the audit log; empty for a provider that asks for none.
|
||||||
|
|
||||||
|
### `api_key_files`
|
||||||
|
|
||||||
|
`[]ExternalKey`
|
||||||
|
|
||||||
|
APIKeyFiles are several keys for the provider, in place of
|
||||||
|
APIKeyFile: one endpoint each (external:MODEL#1, #2…), so a key that
|
||||||
|
answers 429 or 401 is passed over while its breaker is open and the
|
||||||
|
request goes to another. A provider's limits are per key.
|
||||||
|
|
||||||
### `peer`
|
### `peer`
|
||||||
|
|
||||||
`string`
|
`string`
|
||||||
@@ -1135,6 +1207,20 @@ plane.
|
|||||||
|
|
||||||
Output is what it makes: pictures, or meshes (see ComfyUI.Output).
|
Output is what it makes: pictures, or meshes (see ComfyUI.Output).
|
||||||
|
|
||||||
|
## ExternalKey
|
||||||
|
|
||||||
|
### `file`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
File holds the key, on the control plane, as APIKeyFile does.
|
||||||
|
|
||||||
|
### `weight`
|
||||||
|
|
||||||
|
`float64`
|
||||||
|
|
||||||
|
Weight is its share of the requests against the others'; zero is 1.
|
||||||
|
|
||||||
## Facility
|
## Facility
|
||||||
|
|
||||||
### `budgets`
|
### `budgets`
|
||||||
@@ -1235,6 +1321,16 @@ site whose own firewall does that job; the rules are kept, and true
|
|||||||
puts them back. Changed in the cluster's settings, it is in force on
|
puts them back. Changed in the cluster's settings, it is in force on
|
||||||
every node and control plane within seconds.
|
every node and control plane within seconds.
|
||||||
|
|
||||||
|
## FreeTokenConfig
|
||||||
|
|
||||||
|
### `binary`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Binary is its ft command, in the environment it was installed into.
|
||||||
|
The agent installs one itself on a node whose GPUs it runs on; this
|
||||||
|
is for one installed by hand.
|
||||||
|
|
||||||
## GPUNeed
|
## GPUNeed
|
||||||
|
|
||||||
### `min_compute_capability`
|
### `min_compute_capability`
|
||||||
@@ -1401,6 +1497,114 @@ MaxBlocks bounds how many of a prompt's blocks are hashed and looked
|
|||||||
up. Beyond it a longer match still helps, but the difference no longer
|
up. Beyond it a longer match still helps, but the difference no longer
|
||||||
decides anything. Zero takes 4096.
|
decides anything. Zero takes 4096.
|
||||||
|
|
||||||
|
## LDAP
|
||||||
|
|
||||||
|
### `urls`
|
||||||
|
|
||||||
|
`[]string`
|
||||||
|
|
||||||
|
URLs are the directory's servers, tried in turn: ldaps:// (636), or
|
||||||
|
ldap:// (389) with StartTLS.
|
||||||
|
|
||||||
|
### `start_tls`
|
||||||
|
|
||||||
|
`bool`
|
||||||
|
|
||||||
|
StartTLS upgrades an ldap:// connection before anything is sent. A
|
||||||
|
password never crosses a connection without TLS: ldap:// without it
|
||||||
|
is refused.
|
||||||
|
|
||||||
|
### `ca_file`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
CAFile is the authority the servers' certificates are checked against,
|
||||||
|
when it is not the system's: an AD Certificate Services root.
|
||||||
|
|
||||||
|
### `ca`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
CA is the same authority written out, PEM: kept in the cluster's
|
||||||
|
settings, so no file has to be put on every control plane. Used in
|
||||||
|
place of CAFile when both are set.
|
||||||
|
|
||||||
|
### `bind_dn`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
BindDN and the file holding its password: a service account that may
|
||||||
|
read users and groups. Nothing is written to the directory.
|
||||||
|
|
||||||
|
### `bind_password_file`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
### `base_dn`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
BaseDN is where people are searched for, subtree included.
|
||||||
|
|
||||||
|
### `group_base_dn`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
GroupBaseDN is where their groups are; empty is BaseDN's domain root.
|
||||||
|
|
||||||
|
### `user_filter`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
UserFilter narrows who may sign in, ANDed with the login match: a
|
||||||
|
group (memberOf=...) or an OU's people only. Empty is every enabled
|
||||||
|
user object.
|
||||||
|
|
||||||
|
### `label`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Label is what the form says to sign in with: "Sign in with your
|
||||||
|
<label> account". Empty is "Active Directory".
|
||||||
|
|
||||||
|
### `create_accounts`
|
||||||
|
|
||||||
|
`bool`
|
||||||
|
|
||||||
|
CreateAccounts makes an account, active, for a person the directory
|
||||||
|
vouches for and the gateway does not know. Off, only existing accounts
|
||||||
|
sign in this way.
|
||||||
|
|
||||||
|
### `roles`
|
||||||
|
|
||||||
|
`map[string][]string`
|
||||||
|
|
||||||
|
Roles maps a staff role (viewer, operator, billing, admin) to the
|
||||||
|
groups that give it, by name (CN) or whole DN. Set at every sign-in
|
||||||
|
and sync: someone taken out of the group loses the role.
|
||||||
|
|
||||||
|
### `memberships`
|
||||||
|
|
||||||
|
`map[string]string`
|
||||||
|
|
||||||
|
Memberships maps a group, by CN or DN, to a place in the tree —
|
||||||
|
org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT — joined at sign-in
|
||||||
|
and kept by the sync, which also takes away a place whose group the
|
||||||
|
person has left.
|
||||||
|
|
||||||
|
### `sync_interval`
|
||||||
|
|
||||||
|
`time.Duration`
|
||||||
|
|
||||||
|
SyncInterval is how often the leader reads the directory. Zero is
|
||||||
|
15m; negative turns the sync off, leaving only sign-in.
|
||||||
|
|
||||||
|
### `timeout`
|
||||||
|
|
||||||
|
`time.Duration`
|
||||||
|
|
||||||
|
Timeout bounds each round trip to a server. Zero is 10s.
|
||||||
|
|
||||||
## Landing
|
## Landing
|
||||||
|
|
||||||
### `contact`
|
### `contact`
|
||||||
@@ -1539,6 +1743,14 @@ The management port, not the one nodes fetch bytes from. Those are two
|
|||||||
listeners on the mirror precisely so this one can be locked down without
|
listeners on the mirror precisely so this one can be locked down without
|
||||||
re-certifying every agent's fetcher.
|
re-certifying every agent's fetcher.
|
||||||
|
|
||||||
|
### `peers`
|
||||||
|
|
||||||
|
`[]string`
|
||||||
|
|
||||||
|
Peers are the management addresses of mirrors kept in step with this
|
||||||
|
one (llmgw mirror serve --peer): asked when it cannot be reached, and
|
||||||
|
a node given the same file on one of them while it is down.
|
||||||
|
|
||||||
### `tls`
|
### `tls`
|
||||||
|
|
||||||
`tlsconf.Config`
|
`tlsconf.Config`
|
||||||
@@ -1608,6 +1820,16 @@ choice by guesswork.
|
|||||||
|
|
||||||
Source is the weights path or a HuggingFace id.
|
Source is the weights path or a HuggingFace id.
|
||||||
|
|
||||||
|
### `allow_pickle`
|
||||||
|
|
||||||
|
`bool`
|
||||||
|
|
||||||
|
AllowPickle lets the nodes fetch this model's weights, or its
|
||||||
|
adapters', in a pickle format (.bin, .pt, .pth, .ckpt), which run code
|
||||||
|
as they are loaded. Off, such a source is refused when the model is
|
||||||
|
added and by the node before it fetches anything. For a model that has
|
||||||
|
no safetensors or GGUF form and whose files were checked.
|
||||||
|
|
||||||
### `profiles`
|
### `profiles`
|
||||||
|
|
||||||
`[]Profile`
|
`[]Profile`
|
||||||
@@ -1618,6 +1840,16 @@ Source is the weights path or a HuggingFace id.
|
|||||||
|
|
||||||
Takes a [Scaling](#scaling) block.
|
Takes a [Scaling](#scaling) block.
|
||||||
|
|
||||||
|
### `stopped`
|
||||||
|
|
||||||
|
`*Scaling`
|
||||||
|
|
||||||
|
Stopped is the scaling a model had when an administrator stopped it:
|
||||||
|
kept in the catalog with none running (Scaling 0/0) until started
|
||||||
|
again with this. Nil for a model not stopped.
|
||||||
|
|
||||||
|
Takes a [Scaling](#scaling) block.
|
||||||
|
|
||||||
### `class`
|
### `class`
|
||||||
|
|
||||||
`string`
|
`string`
|
||||||
@@ -1965,6 +2197,15 @@ is used, is the more exact source of the peers.
|
|||||||
SkipGPUs omits GPU discovery. A node without nvidia-smi is already
|
SkipGPUs omits GPU discovery. A node without nvidia-smi is already
|
||||||
found to have none, so this is for one whose driver is broken.
|
found to have none, so this is for one whose driver is broken.
|
||||||
|
|
||||||
|
### `dcgm_exporter`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
DCGMExporter is the dcgm-exporter metrics URL read for how much of
|
||||||
|
each card's chip is in use, on cards NVML's GPM does not cover (before
|
||||||
|
Hopper). Empty is http://127.0.0.1:9400/metrics; "off" reads none. A
|
||||||
|
node without one costs a refused connection every five minutes.
|
||||||
|
|
||||||
## Origin
|
## Origin
|
||||||
|
|
||||||
### `repo`
|
### `repo`
|
||||||
@@ -2136,6 +2377,12 @@ Judge has a judge model on the cluster read the text too.
|
|||||||
|
|
||||||
Takes a [JudgePolicy](#judgepolicy) block.
|
Takes a [JudgePolicy](#judgepolicy) block.
|
||||||
|
|
||||||
|
### `services`
|
||||||
|
|
||||||
|
`[]Service`
|
||||||
|
|
||||||
|
Services are outside guardrails asked over HTTP, beside the judge.
|
||||||
|
|
||||||
### `stream`
|
### `stream`
|
||||||
|
|
||||||
`string`
|
`string`
|
||||||
@@ -2365,6 +2612,16 @@ rather than a guess (plan §6.2.1).
|
|||||||
|
|
||||||
Priority orders profiles during placement; highest that fits wins.
|
Priority orders profiles during placement; highest that fits wins.
|
||||||
|
|
||||||
|
### `replicas`
|
||||||
|
|
||||||
|
`int`
|
||||||
|
|
||||||
|
Replicas, set on any of a model's profiles, is how many of its
|
||||||
|
replicas run on this one: seven on cards and four on CPUs, all
|
||||||
|
answering under the model's name, rather than each replica on the
|
||||||
|
best profile that fits. The model's scaling is then the sum, min and
|
||||||
|
max alike; a profile left at zero runs none.
|
||||||
|
|
||||||
### `startup_timeout`
|
### `startup_timeout`
|
||||||
|
|
||||||
`time.Duration`
|
`time.Duration`
|
||||||
@@ -2565,6 +2822,22 @@ as addresses or CIDR prefixes. Their X-Forwarded-For, -Proto and -Host
|
|||||||
are believed; anyone else's are not, since they decide the address
|
are believed; anyone else's are not, since they decide the address
|
||||||
written into the setup scripts and whose attempts are counted where.
|
written into the setup scripts and whose attempts are counted where.
|
||||||
|
|
||||||
|
### `active_active`
|
||||||
|
|
||||||
|
`bool`
|
||||||
|
|
||||||
|
ActiveActive has standby control planes answer the client API
|
||||||
|
themselves, routing to the leader's replicas, rather than passing
|
||||||
|
every request to the leader. More machines carry the traffic, which
|
||||||
|
is the point. Rate limits are the cluster's: each router's counts
|
||||||
|
reach the others through the leader every two seconds, so a key
|
||||||
|
spread over N control planes may pass its limit by what they allow
|
||||||
|
in those two seconds. A budget is checked against the store every
|
||||||
|
half minute; cached answers and prefix affinity are split between
|
||||||
|
the routers. Off by
|
||||||
|
default. Which machine a client reaches is the load balancer's or
|
||||||
|
DNS's to say; the floating address stays on the leader.
|
||||||
|
|
||||||
### `timeouts`
|
### `timeouts`
|
||||||
|
|
||||||
`Timeouts`
|
`Timeouts`
|
||||||
@@ -2610,6 +2883,14 @@ Deny are patterns, matched against the text as a model reads it.
|
|||||||
|
|
||||||
MaxChars refuses text longer than this; zero is no limit.
|
MaxChars refuses text longer than this; zero is no limit.
|
||||||
|
|
||||||
|
### `restore`
|
||||||
|
|
||||||
|
`bool`
|
||||||
|
|
||||||
|
Restore, on input, puts what redact rewrote as placeholders back into
|
||||||
|
the answer: the model reads "[EMAIL_1]", the client reads its own
|
||||||
|
address. Only the values that request sent, only into its answer.
|
||||||
|
|
||||||
## SCIM
|
## SCIM
|
||||||
|
|
||||||
### `token_file`
|
### `token_file`
|
||||||
@@ -3117,6 +3398,67 @@ Endpoints is the phase-1 static replica list. It is only consulted when
|
|||||||
no model declares a profile, which is what makes a router-only smoke test
|
no model declares a profile, which is what makes a router-only smoke test
|
||||||
against sim-engines possible without a control plane.
|
against sim-engines possible without a control plane.
|
||||||
|
|
||||||
|
## Service
|
||||||
|
|
||||||
|
### `name`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Name says which service decided, in the verdict's rule.
|
||||||
|
|
||||||
|
### `url`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
URL is POSTed the body.
|
||||||
|
|
||||||
|
### `header_files`
|
||||||
|
|
||||||
|
`map[string]string`
|
||||||
|
|
||||||
|
HeaderFiles are headers whose values are read from files each time,
|
||||||
|
so a rotated key needs no restart: {Authorization: /etc/…/lakera}.
|
||||||
|
|
||||||
|
### `body`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Body is the request, a template given .Text; {{json .Text}} writes it
|
||||||
|
as a JSON string. Empty is {"input": <text>}.
|
||||||
|
|
||||||
|
### `blocked`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Blocked is where in the answer the service says the text is unsafe:
|
||||||
|
a dotted path, numbers for list items (results.0.flagged). True, a
|
||||||
|
number above zero, a non-empty list or string other than "false" is
|
||||||
|
unsafe.
|
||||||
|
|
||||||
|
### `reason`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Reason is where it says why, for the verdict; optional.
|
||||||
|
|
||||||
|
### `on`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
On: input, output or both. Empty is input.
|
||||||
|
|
||||||
|
### `action`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Action on unsafe: block or flag. Empty is block.
|
||||||
|
|
||||||
|
### `timeout`
|
||||||
|
|
||||||
|
`time.Duration`
|
||||||
|
|
||||||
|
Timeout bounds one call. Empty is 3s; past it the policy's fail says.
|
||||||
|
|
||||||
## ServingSettings
|
## ServingSettings
|
||||||
|
|
||||||
### `stopped`
|
### `stopped`
|
||||||
@@ -3138,6 +3480,27 @@ entries ask. In force on every control plane within seconds.
|
|||||||
Bin is the llmgw binary itself; the engine is one of its subcommands.
|
Bin is the llmgw binary itself; the engine is one of its subcommands.
|
||||||
Empty means whatever started this agent, which is right almost always.
|
Empty means whatever started this agent, which is right almost always.
|
||||||
|
|
||||||
|
## StrataConfig
|
||||||
|
|
||||||
|
### `dir`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Dir is Strata's source directory, where setup.py and serve/ are.
|
||||||
|
|
||||||
|
### `python`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Python is the interpreter of its environment; Dir's .venv when unset.
|
||||||
|
|
||||||
|
### `engine`
|
||||||
|
|
||||||
|
`string`
|
||||||
|
|
||||||
|
Engine is the directory of its prebuilt engine, where the strata
|
||||||
|
binary and the CUDA libraries it ships are; Dir's engine/ when unset.
|
||||||
|
|
||||||
## TRTLLMConfig
|
## TRTLLMConfig
|
||||||
|
|
||||||
### `python`
|
### `python`
|
||||||
@@ -3355,6 +3718,15 @@ is sha256=<hex HMAC of the body>.
|
|||||||
|
|
||||||
`int64`
|
`int64`
|
||||||
|
|
||||||
|
### `cached_per_1k`
|
||||||
|
|
||||||
|
`int64`
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
### `per_image`
|
### `per_image`
|
||||||
|
|
||||||
`int64`
|
`int64`
|
||||||
@@ -3629,6 +4001,14 @@ had rather wait behind people than be turned away.
|
|||||||
|
|
||||||
`int64`
|
`int64`
|
||||||
|
|
||||||
|
### `cached_per_1k`
|
||||||
|
|
||||||
|
`int64`
|
||||||
|
|
||||||
|
CachedPer1k is the cost of a prompt token the engine or provider read
|
||||||
|
from its cache (the answer's cached_tokens, counted within the
|
||||||
|
prompt's). Zero charges them at prompt_per_1k.
|
||||||
|
|
||||||
### `per_image`
|
### `per_image`
|
||||||
|
|
||||||
`int64`
|
`int64`
|
||||||
@@ -3693,6 +4073,25 @@ ComfyUI makes pictures; offered by a node whose ComfyUI is named here.
|
|||||||
|
|
||||||
Takes a [ComfyUIConfig](#comfyuiconfig) block.
|
Takes a [ComfyUIConfig](#comfyuiconfig) block.
|
||||||
|
|
||||||
|
### `strata`
|
||||||
|
|
||||||
|
`StrataConfig`
|
||||||
|
|
||||||
|
Strata serves a few mixture-of-experts models from RAM and one card;
|
||||||
|
offered by a node whose Strata is named here, or that installed it.
|
||||||
|
|
||||||
|
Takes a [StrataConfig](#strataconfig) block.
|
||||||
|
|
||||||
|
### `freetoken`
|
||||||
|
|
||||||
|
`FreeTokenConfig`
|
||||||
|
|
||||||
|
FreeToken serves mixture-of-experts models larger than a card from
|
||||||
|
RAM and one card of 8.0 or newer; installed by the agent where the
|
||||||
|
cards allow it, or named here.
|
||||||
|
|
||||||
|
Takes a [FreeTokenConfig](#freetokenconfig) block.
|
||||||
|
|
||||||
## guard.Config
|
## guard.Config
|
||||||
|
|
||||||
### `enabled`
|
### `enabled`
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -78,10 +78,41 @@ func (a *Accountant) RestoreBudgets(ctx context.Context, s store.Store, period s
|
|||||||
if start.IsZero() {
|
if start.IsZero() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
rows, err := s.SpendSinceByScope(ctx, start)
|
spend, keys, err := storedSpend(ctx, s, start)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("restoring budgets: %w", err)
|
return fmt.Errorf("restoring budgets: %w", err)
|
||||||
}
|
}
|
||||||
|
a.budgets.Restore(period, spend)
|
||||||
|
a.log.Info("restored budgets from the store",
|
||||||
|
"keys", keys, "counters", len(spend), "since", start)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CatchUpBudgets raises each counter to what the store has recorded, where
|
||||||
|
// that is more. Run every so often by a router that is one of several taking
|
||||||
|
// requests: the others' spend reaches it only through the store. Never
|
||||||
|
// lowers a counter, which leads the store by whatever this process has not
|
||||||
|
// written yet.
|
||||||
|
func (a *Accountant) CatchUpBudgets(ctx context.Context, s store.Store, period store.Period) error {
|
||||||
|
start := PeriodStart(period, time.Now())
|
||||||
|
if start.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
spend, _, err := storedSpend(ctx, s, start)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("reading budgets: %w", err)
|
||||||
|
}
|
||||||
|
a.budgets.Raise(period, spend)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// storedSpend is the spend since start the store has recorded, by counter,
|
||||||
|
// and how many keys it came from.
|
||||||
|
func storedSpend(ctx context.Context, s store.Store, start time.Time) (map[string]int64, int, error) {
|
||||||
|
rows, err := s.SpendSinceByScope(ctx, start)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
// Every scope a key sits under, not just the key. A budget owned by a team
|
// Every scope a key sits under, not just the key. A budget owned by a team
|
||||||
// is one counter fed by every key beneath it, and restoring only per-key
|
// is one counter fed by every key beneath it, and restoring only per-key
|
||||||
// totals would bring the control plane back up believing the team had spent
|
// totals would bring the control plane back up believing the team had spent
|
||||||
@@ -113,10 +144,7 @@ func (a *Accountant) RestoreBudgets(ctx context.Context, s store.Store, period s
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.budgets.Restore(period, spend)
|
return spend, len(rows), nil
|
||||||
a.log.Info("restored budgets from the store",
|
|
||||||
"keys", len(rows), "counters", len(spend), "since", start)
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reservation is what Check returns: the estimate it charged, so Record can
|
// Reservation is what Check returns: the estimate it charged, so Record can
|
||||||
@@ -323,7 +351,7 @@ func (a *Accountant) Record(res Reservation, u store.Usage) {
|
|||||||
if u.ServedModel != "" && u.ServedModel != u.Model && a.pricing != nil {
|
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.Images, u.Meshes)
|
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,20 +39,37 @@ 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, 0, 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, 0, 0); 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, 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) {
|
func TestAPictureCostsItsPriceOnTopOfItsPrompt(t *testing.T) {
|
||||||
p := Price{PromptPer1k: 1000, PerImage: 50000}
|
p := Price{PromptPer1k: 1000, PerImage: 50000}
|
||||||
if got := p.Cost(20, 0, 3, 0); got != 20+150000 {
|
if got := p.Cost(20, 0, 0, 3, 0); got != 20+150000 {
|
||||||
t.Errorf("three pictures from a 20-token prompt cost %d", got)
|
t.Errorf("three pictures from a 20-token prompt cost %d", got)
|
||||||
}
|
}
|
||||||
if (Price{PerImage: 1}).Zero() {
|
if (Price{PerImage: 1}).Zero() {
|
||||||
@@ -199,6 +216,22 @@ func TestBudgetSurvivesARestart(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCatchingUpRaisesButNeverLowers(t *testing.T) {
|
||||||
|
// Another router's spend arrives through the store; this one's own, not
|
||||||
|
// written yet, must not be wiped by a store that is behind.
|
||||||
|
b := NewBudgets()
|
||||||
|
b.Add("mine", store.PeriodMonth, 900)
|
||||||
|
b.Add("shared", store.PeriodMonth, 100)
|
||||||
|
|
||||||
|
b.Raise(store.PeriodMonth, map[string]int64{"mine": 400, "shared": 700, "theirs": 50})
|
||||||
|
|
||||||
|
for key, want := range map[string]int64{"mine": 900, "shared": 700, "theirs": 50} {
|
||||||
|
if got := b.Spent(key, store.PeriodMonth); got != want {
|
||||||
|
t.Errorf("%s: spent %d, want %d", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestBudgetsResetAtThePeriodBoundary(t *testing.T) {
|
func TestBudgetsResetAtThePeriodBoundary(t *testing.T) {
|
||||||
b := NewBudgets()
|
b := NewBudgets()
|
||||||
now := time.Date(2026, 1, 31, 23, 59, 0, 0, time.UTC)
|
now := time.Date(2026, 1, 31, 23, 59, 0, 0, time.UTC)
|
||||||
|
|||||||
@@ -61,6 +61,19 @@ func (b *Budgets) Restore(period store.Period, spend map[string]int64) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Raise lifts counters to the spend given, where it is more.
|
||||||
|
func (b *Budgets) Raise(period store.Period, spend map[string]int64) {
|
||||||
|
start := PeriodStart(period, b.now())
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
for key, micros := range spend {
|
||||||
|
if c := b.currentLocked(key, start); micros > c.micros {
|
||||||
|
c.micros = micros
|
||||||
|
b.spend[key] = c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Spent returns a key's spend in the current period, resetting it if the period
|
// Spent returns a key's spend in the current period, resetting it if the period
|
||||||
// has rolled over.
|
// has rolled over.
|
||||||
func (b *Budgets) Spent(keyID string, period store.Period) int64 {
|
func (b *Budgets) Spent(keyID string, period store.Period) int64 {
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ 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
|
// 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
|
// picture's cost is the card's time, which its prompt's few tokens do
|
||||||
// not measure.
|
// not measure.
|
||||||
@@ -33,15 +38,24 @@ type Price struct {
|
|||||||
// 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 {
|
func (p Price) Zero() bool {
|
||||||
return p.PromptPer1k == 0 && p.CompletionPer1k == 0 && p.PerImage == 0 && p.PerMesh == 0
|
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, images, meshes 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
|
max(images, 0)*p.PerImage + max(meshes, 0)*p.PerMesh
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,11 @@ type Limiter struct {
|
|||||||
type window struct {
|
type window struct {
|
||||||
// current and previous are counts in this minute and the one before.
|
// current and previous are counts in this minute and the one before.
|
||||||
current, previous int64
|
current, previous int64
|
||||||
start time.Time
|
// othersCurrent and othersPrevious are the same counts on the other
|
||||||
|
// control planes taking requests, as last heard: what makes a limit the
|
||||||
|
// cluster's rather than each process's.
|
||||||
|
othersCurrent, othersPrevious int64
|
||||||
|
start time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewLimiter builds an empty limiter.
|
// NewLimiter builds an empty limiter.
|
||||||
@@ -104,21 +108,24 @@ func (w *window) roll(now time.Time) {
|
|||||||
return
|
return
|
||||||
case start.Sub(w.start) == windowLen:
|
case start.Sub(w.start) == windowLen:
|
||||||
w.previous, w.current = w.current, 0
|
w.previous, w.current = w.current, 0
|
||||||
|
w.othersPrevious, w.othersCurrent = w.othersCurrent, 0
|
||||||
default:
|
default:
|
||||||
// More than one window has passed with no traffic.
|
// More than one window has passed with no traffic.
|
||||||
w.previous, w.current = 0, 0
|
w.previous, w.current = 0, 0
|
||||||
|
w.othersPrevious, w.othersCurrent = 0, 0
|
||||||
}
|
}
|
||||||
w.start = start
|
w.start = start
|
||||||
}
|
}
|
||||||
|
|
||||||
// rate weights the previous window by how much of it is still in view.
|
// rate weights the previous window by how much of it is still in view.
|
||||||
func (w *window) rate(now time.Time) float64 {
|
func (w *window) rate(now time.Time) float64 {
|
||||||
|
cur, prev := w.current+w.othersCurrent, w.previous+w.othersPrevious
|
||||||
elapsed := now.Sub(w.start)
|
elapsed := now.Sub(w.start)
|
||||||
if elapsed >= windowLen {
|
if elapsed >= windowLen {
|
||||||
return float64(w.current)
|
return float64(cur)
|
||||||
}
|
}
|
||||||
remaining := 1 - float64(elapsed)/float64(windowLen)
|
remaining := 1 - float64(elapsed)/float64(windowLen)
|
||||||
return float64(w.previous)*remaining + float64(w.current)
|
return float64(prev)*remaining + float64(cur)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forget drops a key's window. Used when a key is revoked; keeping it would
|
// Forget drops a key's window. Used when a key is revoked; keeping it would
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package accounting
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Counts are one limiter's counts by counter: this minute's and the one
|
||||||
|
// before, as absolute figures, so reporting them twice changes nothing.
|
||||||
|
type Counts struct {
|
||||||
|
Minute time.Time `json:"minute"`
|
||||||
|
Current map[string]int64 `json:"current,omitempty"`
|
||||||
|
Previous map[string]int64 `json:"previous,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sums other into c, aligned to c's minute: counts of a minute that is
|
||||||
|
// c's previous one land there, older ones are dropped. Clocks a second apart
|
||||||
|
// at a minute's turn are why.
|
||||||
|
func (c *Counts) Add(other Counts) {
|
||||||
|
switch {
|
||||||
|
case other.Minute.Equal(c.Minute):
|
||||||
|
c.Current = addInto(c.Current, other.Current)
|
||||||
|
c.Previous = addInto(c.Previous, other.Previous)
|
||||||
|
case other.Minute.Add(windowLen).Equal(c.Minute):
|
||||||
|
c.Previous = addInto(c.Previous, other.Current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func addInto(dst, src map[string]int64) map[string]int64 {
|
||||||
|
if len(src) == 0 {
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
if dst == nil {
|
||||||
|
dst = make(map[string]int64, len(src))
|
||||||
|
}
|
||||||
|
for k, v := range src {
|
||||||
|
dst[k] += v
|
||||||
|
}
|
||||||
|
return dst
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts reads this limiter's own counts, without the others'.
|
||||||
|
func (l *Limiter) Counts() Counts {
|
||||||
|
now := l.now()
|
||||||
|
out := Counts{Minute: now.Truncate(windowLen), Current: map[string]int64{}, Previous: map[string]int64{}}
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
for k, w := range l.keys {
|
||||||
|
w.roll(now)
|
||||||
|
if w.current > 0 {
|
||||||
|
out.Current[k] = w.current
|
||||||
|
}
|
||||||
|
if w.previous > 0 {
|
||||||
|
out.Previous[k] = w.previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOthers replaces what the other control planes have counted. A counter
|
||||||
|
// they did not mention has nothing from them.
|
||||||
|
func (l *Limiter) SetOthers(c Counts) {
|
||||||
|
now := l.now()
|
||||||
|
aligned := Counts{Minute: now.Truncate(windowLen)}
|
||||||
|
aligned.Add(c)
|
||||||
|
l.mu.Lock()
|
||||||
|
defer l.mu.Unlock()
|
||||||
|
for k, w := range l.keys {
|
||||||
|
w.roll(now)
|
||||||
|
w.othersCurrent, w.othersPrevious = aligned.Current[k], aligned.Previous[k]
|
||||||
|
delete(aligned.Current, k)
|
||||||
|
delete(aligned.Previous, k)
|
||||||
|
}
|
||||||
|
// Counters only others have used yet: a key spread over the cluster is
|
||||||
|
// limited here from its first request.
|
||||||
|
for k := range aligned.Current {
|
||||||
|
w := l.windowLocked(k, now)
|
||||||
|
w.othersCurrent, w.othersPrevious = aligned.Current[k], aligned.Previous[k]
|
||||||
|
delete(aligned.Previous, k)
|
||||||
|
}
|
||||||
|
for k, v := range aligned.Previous {
|
||||||
|
l.windowLocked(k, now).othersPrevious = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rates are an accountant's two limiters' counts.
|
||||||
|
type Rates struct {
|
||||||
|
Requests Counts `json:"requests"`
|
||||||
|
Tokens Counts `json:"tokens"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add sums other into r.
|
||||||
|
func (r *Rates) Add(other Rates) {
|
||||||
|
r.Requests.Add(other.Requests)
|
||||||
|
r.Tokens.Add(other.Tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRates is an empty sum for the minute now is in.
|
||||||
|
func NewRates(now time.Time) Rates {
|
||||||
|
m := now.Truncate(windowLen)
|
||||||
|
return Rates{Requests: Counts{Minute: m}, Tokens: Counts{Minute: m}}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rates reads this process's own request and token counts.
|
||||||
|
func (a *Accountant) Rates() Rates {
|
||||||
|
return Rates{Requests: a.requests.Counts(), Tokens: a.tokens.Counts()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOtherRates puts the other control planes' counts in force here.
|
||||||
|
func (a *Accountant) SetOtherRates(r Rates) {
|
||||||
|
a.requests.SetOthers(r.Requests)
|
||||||
|
a.tokens.SetOthers(r.Tokens)
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package accounting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Two control planes each allowing a key six of its ten a minute: once each
|
||||||
|
// has heard the other's count, neither allows another.
|
||||||
|
func TestALimitIsTheClustersOnceTheCountsAreShared(t *testing.T) {
|
||||||
|
now := time.Date(2026, 11, 1, 12, 0, 10, 0, time.UTC)
|
||||||
|
a, b := NewLimiter(), NewLimiter()
|
||||||
|
a.now, b.now = func() time.Time { return now }, func() time.Time { return now }
|
||||||
|
for range 6 {
|
||||||
|
if !a.Allow("k", 10, 1) || !b.Allow("k", 10, 1) {
|
||||||
|
t.Fatal("refused within the limit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.SetOthers(b.Counts())
|
||||||
|
b.SetOthers(a.Counts())
|
||||||
|
if a.Allow("k", 10, 1) || b.Allow("k", 10, 1) {
|
||||||
|
t.Error("twelve of ten allowed once both knew")
|
||||||
|
}
|
||||||
|
// Reporting the same counts again changes nothing: they are absolute.
|
||||||
|
a.SetOthers(b.Counts())
|
||||||
|
if got := a.Rate("k"); got != 12 {
|
||||||
|
t.Errorf("rate %v after the same counts twice", got)
|
||||||
|
}
|
||||||
|
// What the others counted is never charged back to them.
|
||||||
|
if c := a.Counts(); c.Current["k"] != 6 {
|
||||||
|
t.Errorf("own count %d", c.Current["k"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A key only the others have used is limited here from its first request,
|
||||||
|
// and their counts move into the previous minute with the clock.
|
||||||
|
func TestOthersCountsFollowTheMinute(t *testing.T) {
|
||||||
|
now := time.Date(2026, 11, 1, 12, 0, 50, 0, time.UTC)
|
||||||
|
a, b := NewLimiter(), NewLimiter()
|
||||||
|
a.now, b.now = func() time.Time { return now }, func() time.Time { return now }
|
||||||
|
for range 10 {
|
||||||
|
b.Allow("k", 10, 1)
|
||||||
|
}
|
||||||
|
a.SetOthers(b.Counts())
|
||||||
|
if a.Allow("k", 10, 1) {
|
||||||
|
t.Error("a key the cluster has used up was allowed where it had not been seen")
|
||||||
|
}
|
||||||
|
now = now.Add(20 * time.Second) // 12:01:10, a sixth into the next minute
|
||||||
|
if got := a.Rate("k"); got < 8 || got > 8.5 {
|
||||||
|
t.Errorf("rate %v: the others' last minute should weigh five sixths", got)
|
||||||
|
}
|
||||||
|
// Counts of a minute ago, sent late, land in the previous minute.
|
||||||
|
old := b.Counts()
|
||||||
|
old.Minute = now.Truncate(time.Minute).Add(-time.Minute)
|
||||||
|
old.Current, old.Previous = map[string]int64{"k": 3}, nil
|
||||||
|
a.SetOthers(old)
|
||||||
|
if got := a.Rate("k"); got < 2 || got > 3 {
|
||||||
|
t.Errorf("rate %v from late counts", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRatesAddAlignsMinutes(t *testing.T) {
|
||||||
|
m := time.Date(2026, 11, 1, 12, 1, 0, 0, time.UTC)
|
||||||
|
sum := NewRates(m)
|
||||||
|
sum.Add(Rates{Requests: Counts{Minute: m, Current: map[string]int64{"k": 2}, Previous: map[string]int64{"k": 1}}})
|
||||||
|
sum.Add(Rates{Requests: Counts{Minute: m.Add(-time.Minute), Current: map[string]int64{"k": 5}}})
|
||||||
|
sum.Add(Rates{Requests: Counts{Minute: m.Add(-2 * time.Minute), Current: map[string]int64{"k": 100}}})
|
||||||
|
if sum.Requests.Current["k"] != 2 || sum.Requests.Previous["k"] != 6 {
|
||||||
|
t.Errorf("sum %+v", sum.Requests)
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
-14
@@ -6,11 +6,13 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"maps"
|
"maps"
|
||||||
|
"math"
|
||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -45,7 +47,9 @@ import (
|
|||||||
|
|
||||||
// Agent runs on a GPU node.
|
// Agent runs on a GPU node.
|
||||||
type Agent struct {
|
type Agent struct {
|
||||||
cfg Config
|
// upgrading is true while an upgrade downloads.
|
||||||
|
upgrading atomic.Bool
|
||||||
|
cfg Config
|
||||||
// answered says the current session heard from its control plane, which
|
// answered says the current session heard from its control plane, which
|
||||||
// starts the reconnect backoff again when it ends.
|
// starts the reconnect backoff again when it ends.
|
||||||
answered atomic.Bool
|
answered atomic.Bool
|
||||||
@@ -183,7 +187,7 @@ func New(cfg Config, httpClient connectHTTPClient, log *slog.Logger) (*Agent, er
|
|||||||
logRing: ring,
|
logRing: ring,
|
||||||
provKick: make(chan struct{}, 1),
|
provKick: make(chan struct{}, 1),
|
||||||
load: discovery.NewLoadSampler(),
|
load: discovery.NewLoadSampler(),
|
||||||
sensors: discovery.NewSensorSampler(),
|
sensors: discovery.NewSensorSampler().WithProfile(discovery.NewProfSampler(cfg.Discovery.DCGMExporter)),
|
||||||
wear: discovery.NewHealthSampler(),
|
wear: discovery.NewHealthSampler(),
|
||||||
serverList: loadControlPlanes(cfg.Servers, cfg.TLS.Enabled()),
|
serverList: loadControlPlanes(cfg.Servers, cfg.TLS.Enabled()),
|
||||||
kv: kvevents.NewMirror(),
|
kv: kvevents.NewMirror(),
|
||||||
@@ -391,8 +395,11 @@ func (a *Agent) settleAdopted(sh *supervisor.Shard, o supervisor.Orphan) {
|
|||||||
_ = a.sup.Stop(ctx, o.ShardID)
|
_ = a.sup.Stop(ctx, o.ShardID)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// What it computes on, for one started before the agent kept that.
|
// What it computes on, for one started before the agent kept that — or
|
||||||
if a.sup.ComputeOf(o.ShardID) == nil {
|
// kept as the CPU for a GPU placement by an agent that did not follow
|
||||||
|
// an engine into a process group of its own, which the check above has
|
||||||
|
// just found on its GPUs.
|
||||||
|
if c := a.sup.ComputeOf(o.ShardID); c == nil || (len(o.Spec.GPUIndices) > 0 && c.Backend == "CPU") {
|
||||||
if c, ok := a.gpuCompute(ctx, o.Spec.GPUIndices, sh.Status().PID); ok {
|
if c, ok := a.gpuCompute(ctx, o.Spec.GPUIndices, sh.Status().PID); ok {
|
||||||
a.sup.SetCompute(o.ShardID, c)
|
a.sup.SetCompute(o.ShardID, c)
|
||||||
}
|
}
|
||||||
@@ -512,6 +519,8 @@ func (a *Agent) scan(ctx context.Context) error {
|
|||||||
inv, err := discovery.Discover(ctx, opts, a.log)
|
inv, err := discovery.Discover(ctx, opts, a.log)
|
||||||
host := discovery.DiscoverHost()
|
host := discovery.DiscoverHost()
|
||||||
host.MinComputeCapability = discovery.MinComputeCapability(inv.GPUs)
|
host.MinComputeCapability = discovery.MinComputeCapability(inv.GPUs)
|
||||||
|
bw := a.memBandwidth()
|
||||||
|
host.MemBandwidthGBs, host.MemBandwidthCoreGBs = bw.All, bw.Core
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
a.inv = inv
|
a.inv = inv
|
||||||
a.prov.Host = host
|
a.prov.Host = host
|
||||||
@@ -568,6 +577,10 @@ func (a *Agent) session(ctx context.Context, server string) error {
|
|||||||
reg.Host = &llmgwv1.Host{
|
reg.Host = &llmgwv1.Host{
|
||||||
OsId: h.OSID, OsVersion: h.OSVersion, OsCodename: h.OSCodename,
|
OsId: h.OSID, OsVersion: h.OSVersion, OsCodename: h.OSCodename,
|
||||||
Arch: h.Arch, CpuFlags: h.CPUFlags, NvidiaDriver: h.NVIDIADriver,
|
Arch: h.Arch, CpuFlags: h.CPUFlags, NvidiaDriver: h.NVIDIADriver,
|
||||||
|
// What is measured by now, not what the startup scan found before
|
||||||
|
// the measurement ended: a reconnect would otherwise say 0.
|
||||||
|
MemBandwidthGbs: a.memBandwidth().All,
|
||||||
|
MemBandwidthCoreGbs: a.memBandwidth().Core,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := stream.Send(&llmgwv1.AgentMessage{
|
if err := stream.Send(&llmgwv1.AgentMessage{
|
||||||
@@ -665,6 +678,8 @@ func (a *Agent) heartbeat() *llmgwv1.AgentMessage {
|
|||||||
// without anybody restarting anything.
|
// without anybody restarting anything.
|
||||||
hb.MemoryTotalMb = int64(discovery.MemoryTotalMB())
|
hb.MemoryTotalMb = int64(discovery.MemoryTotalMB())
|
||||||
hb.CpuCount = int32(runtime.NumCPU())
|
hb.CpuCount = int32(runtime.NumCPU())
|
||||||
|
bw := a.memBandwidth()
|
||||||
|
hb.MemBandwidthGbs, hb.MemBandwidthCoreGbs = bw.All, bw.Core
|
||||||
|
|
||||||
a.reprobeEngines()
|
a.reprobeEngines()
|
||||||
a.mu.Lock()
|
a.mu.Lock()
|
||||||
@@ -786,6 +801,11 @@ func (a *Agent) sampleLoad() *llmgwv1.NodeLoad {
|
|||||||
MemoryUsedMb: g.MemoryUsedMB, MemoryTotalMb: g.MemoryTotalMB,
|
MemoryUsedMb: g.MemoryUsedMB, MemoryTotalMb: g.MemoryTotalMB,
|
||||||
SmClockMhz: g.SMClockMHz, EccUncorrected: g.ECCUncorrected,
|
SmClockMhz: g.SMClockMHz, EccUncorrected: g.ECCUncorrected,
|
||||||
}
|
}
|
||||||
|
if p := g.Profile; p != nil {
|
||||||
|
gs.HasProfile, gs.ProfileSource = true, p.Source
|
||||||
|
gs.SmActivePercent, gs.SmOccupancyPercent = p.SMActive, p.SMOccupancy
|
||||||
|
gs.TensorActivePercent, gs.DramActivePercent = p.TensorActive, p.DRAMActive
|
||||||
|
}
|
||||||
if w, ok := wear[g.Index]; ok {
|
if w, ok := wear[g.Index]; ok {
|
||||||
gs.HasWear = true
|
gs.HasWear = true
|
||||||
gs.PcieReplays, gs.RetiredPagesSbe, gs.RetiredPagesDbe, gs.RetirementPending =
|
gs.PcieReplays, gs.RetiredPagesSbe, gs.RetiredPagesDbe, gs.RetirementPending =
|
||||||
@@ -868,6 +888,7 @@ func (a *Agent) removeWeights(paths []string) {
|
|||||||
a.log.Warn("could not remove cached weights", "path", p, "err", err)
|
a.log.Warn("could not remove cached weights", "path", p, "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
_ = os.Remove(weights.ChecksumPath(abs))
|
||||||
a.log.Info("removed cached weights no model asks for", "path", p)
|
a.log.Info("removed cached weights no model asks for", "path", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1119,11 +1140,11 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
|
|||||||
send(&llmgwv1.ShardStatus{
|
send(&llmgwv1.ShardStatus{
|
||||||
State: agentsvc.StateToWire(supervisor.Starting),
|
State: agentsvc.StateToWire(supervisor.Starting),
|
||||||
Fetch: &llmgwv1.FetchProgress{
|
Fetch: &llmgwv1.FetchProgress{
|
||||||
Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal,
|
Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal, Phase: p.Phase,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if path, err := a.weights().Ensure(ctx, spec.Profile.ModelPath); err != nil {
|
if path, err := a.weights().EnsureChecked(ctx, spec.Profile.ModelPath, spec.Profile.AllowPickle, spec.Profile.ModelSHA256); err != nil {
|
||||||
stop()
|
stop()
|
||||||
report(supervisor.Failed, err.Error(), nil)
|
report(supervisor.Failed, err.Error(), nil)
|
||||||
return
|
return
|
||||||
@@ -1131,11 +1152,37 @@ 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,
|
||||||
|
Phase: p.Phase}})
|
||||||
|
})
|
||||||
|
path, err := a.weights().Ensure(ctx, m["source"], m["allow_pickle"] == "true")
|
||||||
|
stop()
|
||||||
|
if err != nil {
|
||||||
|
report(supervisor.Failed, fmt.Sprintf("model %s: %v", m["name"], err), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ms[i]["path"] = path
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(ms)
|
||||||
|
spec.Profile.Env = maps.Clone(spec.Profile.Env)
|
||||||
|
spec.Profile.Env[engines.SharedModelsEnv] = string(b)
|
||||||
|
}
|
||||||
// Adapters are fetched like the model, and before the engine starts: it
|
// 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.
|
||||||
for i, ad := range spec.Profile.Adapters {
|
for i, ad := range spec.Profile.Adapters {
|
||||||
path, err := a.weights().Ensure(ctx, ad.Path)
|
path, err := a.weights().Ensure(ctx, ad.Path, spec.Profile.AllowPickle)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
report(supervisor.Failed, fmt.Sprintf("adapter %s: %v", ad.Name, err), nil)
|
report(supervisor.Failed, fmt.Sprintf("adapter %s: %v", ad.Name, err), nil)
|
||||||
return
|
return
|
||||||
@@ -1391,11 +1438,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.
|
||||||
@@ -1476,18 +1526,25 @@ func ResolveManagement(c Config) (addr, iface string) {
|
|||||||
// shard computes on as soon as that is known: an adopted engine's is found
|
// shard computes on as soon as that is known: an adopted engine's is found
|
||||||
// after registration, which carried what was known then.
|
// after registration, which carried what was known then.
|
||||||
func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMessage) {
|
func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMessage) {
|
||||||
sent := map[string]bool{}
|
// What was sent for each shard, so a compute that changes is sent again:
|
||||||
|
// an adopted shard's kept one is sent at once, and adopting it may then
|
||||||
|
// find it was kept wrong.
|
||||||
|
sent := map[string]string{}
|
||||||
t := time.NewTicker(5 * time.Second)
|
t := time.NewTicker(5 * time.Second)
|
||||||
defer t.Stop()
|
defer t.Stop()
|
||||||
for range 60 {
|
for range 60 {
|
||||||
for _, st := range a.sup.Statuses() {
|
for _, st := range a.sup.Statuses() {
|
||||||
if st.State.Terminal() || sent[st.ID] {
|
if st.State.Terminal() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
c := a.sup.ComputeOf(st.ID)
|
c := a.sup.ComputeOf(st.ID)
|
||||||
if c == nil {
|
if c == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
said := fmt.Sprint(*c)
|
||||||
|
if sent[st.ID] == said {
|
||||||
|
continue
|
||||||
|
}
|
||||||
sh, err := a.sup.Get(st.ID)
|
sh, err := a.sup.Get(st.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
@@ -1498,7 +1555,7 @@ func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMess
|
|||||||
}}}
|
}}}
|
||||||
select {
|
select {
|
||||||
case out <- msg:
|
case out <- msg:
|
||||||
sent[st.ID] = true
|
sent[st.ID] = said
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -1510,3 +1567,51 @@ func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMess
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// memBandwidth is the node's memory read rate in GB/s, 0 until measured.
|
||||||
|
// Measured in the background, the first time it is asked for: it reads half
|
||||||
|
// a gigabyte, which must not hold up an agent reaching its control plane,
|
||||||
|
// and the heartbeat carries it when it is known. Not at once but after a
|
||||||
|
// minute or so, chosen at random, and again every hour, keeping the best:
|
||||||
|
// VMs on one host that all restart together measure each other, and read a
|
||||||
|
// third less than any of them gets alone. Once a process rather than an
|
||||||
|
// agent, so tests running many agents in one read it once.
|
||||||
|
func (a *Agent) memBandwidth() discovery.Bandwidth {
|
||||||
|
bwOnce.Do(func() {
|
||||||
|
go func() {
|
||||||
|
time.Sleep(bandwidthDelay())
|
||||||
|
for {
|
||||||
|
// The best of each seen so far: an hourly pass on a node
|
||||||
|
// busy serving reads less, and that is not the memory.
|
||||||
|
bw := measureBandwidth()
|
||||||
|
cur := a.memBandwidthNow()
|
||||||
|
if bw.All > cur.All || bw.Core > cur.Core {
|
||||||
|
bwBits.Store(math.Float64bits(max(bw.All, cur.All)))
|
||||||
|
coreBits.Store(math.Float64bits(max(bw.Core, cur.Core)))
|
||||||
|
a.log.Info("this node's memory", "read_gbs", math.Round(bw.All*10)/10,
|
||||||
|
"one_core_gbs", math.Round(bw.Core*10)/10)
|
||||||
|
}
|
||||||
|
time.Sleep(time.Hour)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
})
|
||||||
|
return a.memBandwidthNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// memBandwidthNow is what has been measured so far, without starting a
|
||||||
|
// measurement.
|
||||||
|
func (a *Agent) memBandwidthNow() discovery.Bandwidth {
|
||||||
|
return discovery.Bandwidth{All: math.Float64frombits(bwBits.Load()), Core: math.Float64frombits(coreBits.Load())}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bandwidthDelay is how long an agent waits before measuring, so agents
|
||||||
|
// started together measure apart.
|
||||||
|
var bandwidthDelay = func() time.Duration { return 10*time.Second + rand.N(110*time.Second) }
|
||||||
|
|
||||||
|
var (
|
||||||
|
bwOnce sync.Once
|
||||||
|
bwBits atomic.Uint64
|
||||||
|
coreBits atomic.Uint64
|
||||||
|
// measureBandwidth is replaced in tests, which have no use for it.
|
||||||
|
measureBandwidth = discovery.MeasureMemoryBandwidth
|
||||||
|
)
|
||||||
@@ -68,7 +68,7 @@ func (r *statusRecorder) OnShardCompute(replicaID, shardID string, c reconciler.
|
|||||||
r.computes = append(r.computes, c)
|
r.computes = append(r.computes, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *statusRecorder) OnFetchProgress(replicaID, shardID, source string, done, total int64) {
|
func (r *statusRecorder) OnFetchProgress(replicaID, shardID, source, phase string, done, total int64) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
r.fetches = append(r.fetches, fetchReport{replicaID, shardID, source, done, total})
|
r.fetches = append(r.fetches, fetchReport{replicaID, shardID, source, done, total})
|
||||||
@@ -671,7 +671,7 @@ func TestAShardWithMissingWeightsFailsBeforeLaunching(t *testing.T) {
|
|||||||
// nobody is watching — so it is caught here, where the message can say
|
// nobody is watching — so it is caught here, where the message can say
|
||||||
// what is actually wrong.
|
// what is actually wrong.
|
||||||
a := &Agent{cfg: Config{WeightsCache: t.TempDir()}, log: slog.New(slog.DiscardHandler)}
|
a := &Agent{cfg: Config{WeightsCache: t.TempDir()}, log: slog.New(slog.DiscardHandler)}
|
||||||
_, err := a.weights().Ensure(t.Context(), filepath.Join(t.TempDir(), "not-there.gguf"))
|
_, err := a.weights().Ensure(t.Context(), filepath.Join(t.TempDir(), "not-there.gguf"), false)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("a missing weights file was accepted")
|
t.Fatal("a missing weights file was accepted")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CUDAToolkit is the root of an installed CUDA toolkit of this major version
|
||||||
|
// — the directory whose bin holds nvcc — or "" when there is none. Engines
|
||||||
|
// that compile their kernels where they run (FreeToken, ComfyUI's compiled
|
||||||
|
// nodes) need one matching the CUDA their torch was built for, and a
|
||||||
|
// toolkit of another major version compiles code that torch cannot load.
|
||||||
|
//
|
||||||
|
// CUDA_HOME is looked at first, then /usr/local/cuda, then every
|
||||||
|
// /usr/local/cuda-<version>, newest first.
|
||||||
|
func CUDAToolkit(major int) string {
|
||||||
|
return cudaToolkitIn(major, os.Getenv("CUDA_HOME"), "/usr/local")
|
||||||
|
}
|
||||||
|
|
||||||
|
func cudaToolkitIn(major int, home, root string) string {
|
||||||
|
var dirs []string
|
||||||
|
if home != "" {
|
||||||
|
dirs = append(dirs, home)
|
||||||
|
}
|
||||||
|
dirs = append(dirs, filepath.Join(root, "cuda"))
|
||||||
|
versioned, _ := filepath.Glob(filepath.Join(root, "cuda-*"))
|
||||||
|
slices.SortFunc(versioned, func(a, b string) int { return versionCompare(b, a) })
|
||||||
|
dirs = append(dirs, versioned...)
|
||||||
|
for _, d := range dirs {
|
||||||
|
if _, err := os.Stat(filepath.Join(d, "bin", "nvcc")); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if toolkitMajor(d) == major {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolkitMajor is a toolkit's major version, from the version.json every
|
||||||
|
// toolkit since 11.1 carries, or from nvcc where it has none; 0 when neither
|
||||||
|
// says.
|
||||||
|
func toolkitMajor(dir string) int {
|
||||||
|
if b, err := os.ReadFile(filepath.Join(dir, "version.json")); err == nil { //nolint:gosec // a toolkit's own file
|
||||||
|
var v struct {
|
||||||
|
CUDA struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
} `json:"cuda"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(b, &v) == nil && v.CUDA.Version != "" {
|
||||||
|
n, _ := strconv.Atoi(strings.SplitN(v.CUDA.Version, ".", 2)[0])
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//nolint:gosec // nvcc of a toolkit found on disk
|
||||||
|
out, err := exec.Command(filepath.Join(dir, "bin", "nvcc"), "--version").Output()
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if m := nvccRelease.FindSubmatch(out); m != nil {
|
||||||
|
n, _ := strconv.Atoi(string(m[1]))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var nvccRelease = regexp.MustCompile(`release (\d+)\.`)
|
||||||
|
|
||||||
|
// versionCompare orders two paths ending in dotted versions: cuda-13.10 is
|
||||||
|
// newer than cuda-13.4, which comparing them as text gets wrong.
|
||||||
|
func versionCompare(a, b string) int {
|
||||||
|
pa := strings.Split(strings.TrimPrefix(filepath.Base(a), "cuda-"), ".")
|
||||||
|
pb := strings.Split(strings.TrimPrefix(filepath.Base(b), "cuda-"), ".")
|
||||||
|
for i := range max(len(pa), len(pb)) {
|
||||||
|
var x, y int
|
||||||
|
if i < len(pa) {
|
||||||
|
x, _ = strconv.Atoi(pa[i])
|
||||||
|
}
|
||||||
|
if i < len(pb) {
|
||||||
|
y, _ = strconv.Atoi(pb[i])
|
||||||
|
}
|
||||||
|
if x != y {
|
||||||
|
return x - y
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func toolkit(t *testing.T, root, name, version string) string {
|
||||||
|
t.Helper()
|
||||||
|
d := filepath.Join(root, name)
|
||||||
|
put(t, d, "bin/nvcc", "#!/bin/sh")
|
||||||
|
if version != "" {
|
||||||
|
put(t, d, "version.json", `{"cuda":{"name":"CUDA SDK","version":"`+version+`"}}`)
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheNewestToolkitOfTheMajorVersionAskedForIsFound(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
toolkit(t, root, "cuda-12.8", "12.8.1")
|
||||||
|
toolkit(t, root, "cuda-13.4", "13.4.0")
|
||||||
|
want := toolkit(t, root, "cuda-13.10", "13.10.0")
|
||||||
|
// /usr/local/cuda is a 12: a toolkit of the wrong major version is not
|
||||||
|
// one torch built for 13 can load what it compiles.
|
||||||
|
toolkit(t, root, "cuda", "12.8.1")
|
||||||
|
if got := cudaToolkitIn(13, "", root); got != want {
|
||||||
|
t.Errorf("got %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
if got := cudaToolkitIn(12, "", root); got != filepath.Join(root, "cuda") {
|
||||||
|
t.Errorf("12: got %q", got)
|
||||||
|
}
|
||||||
|
// CUDA_HOME first, when it is one.
|
||||||
|
home := toolkit(t, t.TempDir(), "mine", "13.0.2")
|
||||||
|
if got := cudaToolkitIn(13, home, root); got != home {
|
||||||
|
t.Errorf("CUDA_HOME: got %q", got)
|
||||||
|
}
|
||||||
|
if got := cudaToolkitIn(14, "", root); got != "" {
|
||||||
|
t.Errorf("14: got %q", got)
|
||||||
|
}
|
||||||
|
// A directory without nvcc is not a toolkit.
|
||||||
|
if err := os.MkdirAll(filepath.Join(root, "cuda-13.99"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := cudaToolkitIn(13, "", root); got != want {
|
||||||
|
t.Errorf("with an empty cuda-13.99: got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ type Options struct {
|
|||||||
// SkipGPUs omits GPU discovery. A node without nvidia-smi is already
|
// SkipGPUs omits GPU discovery. A node without nvidia-smi is already
|
||||||
// found to have none, so this is for one whose driver is broken.
|
// found to have none, so this is for one whose driver is broken.
|
||||||
SkipGPUs bool `koanf:"skip_gpus"`
|
SkipGPUs bool `koanf:"skip_gpus"`
|
||||||
|
// DCGMExporter is the dcgm-exporter metrics URL read for how much of
|
||||||
|
// each card's chip is in use, on cards NVML's GPM does not cover (before
|
||||||
|
// Hopper). Empty is http://127.0.0.1:9400/metrics; "off" reads none. A
|
||||||
|
// node without one costs a refused connection every five minutes.
|
||||||
|
DCGMExporter string `koanf:"dcgm_exporter"`
|
||||||
// ModelDirs are scanned for weights. Empty means do not look.
|
// ModelDirs are scanned for weights. Empty means do not look.
|
||||||
ModelDirs []string `koanf:"-"`
|
ModelDirs []string `koanf:"-"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
//go:build !nvml
|
||||||
|
|
||||||
|
package discovery
|
||||||
|
|
||||||
|
// newGPM has nothing to read GPM with in a build without the nvml tag.
|
||||||
|
func newGPM() gpmSource { return nil }
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
//go:build nvml
|
||||||
|
|
||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/NVIDIA/go-nvml/pkg/nvml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// nvmlGPM reads GPM, NVML's own profiling counters, on the cards that have
|
||||||
|
// them: Hopper and later. Each read takes a sample and works out the metrics
|
||||||
|
// against the one before, so NVML stays initialised between reads.
|
||||||
|
type nvmlGPM struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
opened bool
|
||||||
|
failed bool
|
||||||
|
cards map[int]*gpmCard
|
||||||
|
}
|
||||||
|
|
||||||
|
type gpmCard struct {
|
||||||
|
dev nvml.Device
|
||||||
|
prev, cur nvml.GpmSample
|
||||||
|
havePrevious bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newGPM() gpmSource { return &nvmlGPM{} }
|
||||||
|
|
||||||
|
// The GPM metrics read, in the order they are asked for.
|
||||||
|
var gpmMetrics = []struct {
|
||||||
|
id nvml.GpmMetricId
|
||||||
|
set func(*GPUProfile, float64)
|
||||||
|
}{
|
||||||
|
{nvml.GPM_METRIC_SM_UTIL, func(p *GPUProfile, v float64) { p.SMActive = v }},
|
||||||
|
{nvml.GPM_METRIC_SM_OCCUPANCY, func(p *GPUProfile, v float64) { p.SMOccupancy = v }},
|
||||||
|
{nvml.GPM_METRIC_ANY_TENSOR_UTIL, func(p *GPUProfile, v float64) { p.TensorActive = v }},
|
||||||
|
{nvml.GPM_METRIC_DRAM_BW_UTIL, func(p *GPUProfile, v float64) { p.DRAMActive = v }},
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *nvmlGPM) read() map[int]GPUProfile {
|
||||||
|
g.mu.Lock()
|
||||||
|
defer g.mu.Unlock()
|
||||||
|
if g.failed {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !g.opened && !g.open() {
|
||||||
|
g.failed = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := map[int]GPUProfile{}
|
||||||
|
for idx, c := range g.cards {
|
||||||
|
if c.cur.Get(c.dev) != nvml.SUCCESS {
|
||||||
|
// A card in MIG mode, or one that has gone: no sample, and the
|
||||||
|
// next must not be measured against an old one.
|
||||||
|
c.havePrevious = false
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.havePrevious {
|
||||||
|
if p, ok := gpmBetween(c.prev, c.cur); ok {
|
||||||
|
out[idx] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.prev, c.cur = c.cur, c.prev
|
||||||
|
c.havePrevious = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// open finds the cards with GPM. False is NVML not starting, or no card
|
||||||
|
// having it, and the reader then never tries again.
|
||||||
|
func (g *nvmlGPM) open() bool {
|
||||||
|
if nvml.Init() != nvml.SUCCESS {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
g.cards = map[int]*gpmCard{}
|
||||||
|
n, ret := nvml.DeviceGetCount()
|
||||||
|
if ret != nvml.SUCCESS {
|
||||||
|
n = 0
|
||||||
|
}
|
||||||
|
for i := range n {
|
||||||
|
dev, ret := nvml.DeviceGetHandleByIndex(i)
|
||||||
|
if ret != nvml.SUCCESS {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sup, ret := dev.GpmQueryDeviceSupport()
|
||||||
|
if ret != nvml.SUCCESS || sup.IsSupportedDevice == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
a, ret := nvml.GpmSampleAlloc()
|
||||||
|
if ret != nvml.SUCCESS {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b, ret := nvml.GpmSampleAlloc()
|
||||||
|
if ret != nvml.SUCCESS {
|
||||||
|
_ = a.Free()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
g.cards[i] = &gpmCard{dev: dev, prev: a, cur: b}
|
||||||
|
}
|
||||||
|
if len(g.cards) == 0 {
|
||||||
|
_ = nvml.Shutdown()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
g.opened = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// gpmBetween is the profile over the time between two samples.
|
||||||
|
func gpmBetween(earlier, later nvml.GpmSample) (GPUProfile, bool) {
|
||||||
|
q := nvml.GpmMetricsGetType{NumMetrics: uint32(len(gpmMetrics)), Sample1: earlier, Sample2: later}
|
||||||
|
for i, m := range gpmMetrics {
|
||||||
|
q.Metrics[i].MetricId = uint32(m.id)
|
||||||
|
}
|
||||||
|
if nvml.GpmMetricsGet(&q) != nvml.SUCCESS {
|
||||||
|
return GPUProfile{}, false
|
||||||
|
}
|
||||||
|
p := GPUProfile{SMActive: -1, SMOccupancy: -1, TensorActive: -1, DRAMActive: -1, Source: "gpm"}
|
||||||
|
for i, m := range gpmMetrics {
|
||||||
|
if r := q.Metrics[i]; r.NvmlReturn == uint32(nvml.SUCCESS) && r.Value >= 0 {
|
||||||
|
m.set(&p, r.Value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
@@ -52,6 +52,8 @@ type GPU struct {
|
|||||||
// memory the instance's share. Utilisation stays unreported, because no
|
// memory the instance's share. Utilisation stays unreported, because no
|
||||||
// driver measures it per instance.
|
// driver measures it per instance.
|
||||||
MIG *MIGDevice `json:"mig,omitempty"`
|
MIG *MIGDevice `json:"mig,omitempty"`
|
||||||
|
// PCIe is how the card is linked to its host, zero when not read.
|
||||||
|
PCIe PCIeLink `json:"pcie,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GPUHealth records conditions that make a GPU unsafe to schedule onto.
|
// GPUHealth records conditions that make a GPU unsafe to schedule onto.
|
||||||
|
|||||||
@@ -67,10 +67,23 @@ func DiscoverGPUsNVML(_ context.Context) ([]GPU, error) {
|
|||||||
if major, minor, ret := dev.GetCudaComputeCapability(); ret == nvml.SUCCESS {
|
if major, minor, ret := dev.GetCudaComputeCapability(); ret == nvml.SUCCESS {
|
||||||
g.ComputeCapability = fmt.Sprintf("%d.%d", major, minor)
|
g.ComputeCapability = fmt.Sprintf("%d.%d", major, minor)
|
||||||
}
|
}
|
||||||
|
if v, ret := dev.GetMaxPcieLinkGeneration(); ret == nvml.SUCCESS {
|
||||||
|
g.PCIe.Gen = v
|
||||||
|
}
|
||||||
|
if v, ret := dev.GetGpuMaxPcieLinkGeneration(); ret == nvml.SUCCESS {
|
||||||
|
g.PCIe.GPUGen = v
|
||||||
|
}
|
||||||
|
if v, ret := dev.GetCurrPcieLinkWidth(); ret == nvml.SUCCESS {
|
||||||
|
g.PCIe.Width = v
|
||||||
|
}
|
||||||
|
if v, ret := dev.GetMaxPcieLinkWidth(); ret == nvml.SUCCESS {
|
||||||
|
g.PCIe.WidthMax = v
|
||||||
|
}
|
||||||
if pci, ret := dev.GetPciInfo(); ret == nvml.SUCCESS {
|
if pci, ret := dev.GetPciInfo(); ret == nvml.SUCCESS {
|
||||||
g.PCIBusID = busIDString(pci.BusId[:])
|
g.PCIBusID = busIDString(pci.BusId[:])
|
||||||
byBusID[normalizeBusID(g.PCIBusID)] = i
|
byBusID[normalizeBusID(g.PCIBusID)] = i
|
||||||
}
|
}
|
||||||
|
g.PCIe = withSysfs(g.PCIe, "/sys/bus/pci/devices", g.PCIBusID)
|
||||||
if t, ret := dev.GetTemperature(nvml.TEMPERATURE_GPU); ret == nvml.SUCCESS {
|
if t, ret := dev.GetTemperature(nvml.TEMPERATURE_GPU); ret == nvml.SUCCESS {
|
||||||
g.Health.TemperatureC = int(t)
|
g.Health.TemperatureC = int(t)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ func DiscoverGPUsSMI(ctx context.Context, binary string) ([]GPU, error) {
|
|||||||
if err != nil || len(cards) == 0 {
|
if err != nil || len(cards) == 0 {
|
||||||
return cards, err
|
return cards, err
|
||||||
}
|
}
|
||||||
|
readPCIe(ctx, binary, cards)
|
||||||
// The query lists whole cards, MIG mode or not. A card in MIG mode is
|
// The query lists whole cards, MIG mode or not. A card in MIG mode is
|
||||||
// replaced by its instances, which only -L names and only the full
|
// replaced by its instances, which only -L names and only the full
|
||||||
// report sizes; asked only when some card could be in MIG mode, since
|
// report sizes; asked only when some card could be in MIG mode, since
|
||||||
|
|||||||
@@ -31,6 +31,14 @@ type Host struct {
|
|||||||
// MinComputeCapability is the oldest GPU's, 0 when there are none or it
|
// MinComputeCapability is the oldest GPU's, 0 when there are none or it
|
||||||
// is not known. Not read here but from the GPU inventory, by the agent.
|
// is not known. Not read here but from the GPU inventory, by the agent.
|
||||||
MinComputeCapability float64 `json:"min_compute_capability,omitempty"`
|
MinComputeCapability float64 `json:"min_compute_capability,omitempty"`
|
||||||
|
// MemBandwidthGBs is how fast the memory reads, in GB/s, 0 when not
|
||||||
|
// measured: what decides a model's speed on the CPU. Not read here but
|
||||||
|
// measured by the agent when it starts (MeasureMemoryBandwidth).
|
||||||
|
MemBandwidthGBs float64 `json:"mem_bandwidth_gbs,omitempty"`
|
||||||
|
// MemBandwidthCoreGBs is what one core reads alone, in GB/s, 0 when not
|
||||||
|
// measured: a replica given n threads reads at about n times this, up to
|
||||||
|
// MemBandwidthGBs.
|
||||||
|
MemBandwidthCoreGBs float64 `json:"mem_bandwidth_core_gbs,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Family is the packaging family: "deb", "rpm", or "" when neither is known.
|
// Family is the packaging family: "deb", "rpm", or "" when neither is known.
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// How fast this machine reads its memory, which on a CPU is what decides how
|
||||||
|
// fast a model answers: every token of a reply reads all the weights it uses
|
||||||
|
// once, so a node that reads 40 GB/s writes a 4 GB model's tokens about ten
|
||||||
|
// times a second, whatever its core count. Measured once when the agent
|
||||||
|
// starts, in a fraction of a second, by every CPU summing a share of a buffer
|
||||||
|
// too large for any cache — a VM sharing its host's memory with others gets
|
||||||
|
// its share, which is the number that matters for it.
|
||||||
|
|
||||||
|
// bandwidthBuffer is what is read at most; the least is eight times the
|
||||||
|
// processor's last cache, and never under minBuffer. Smaller, it would be
|
||||||
|
// the cache that was measured — some hold a gigabyte — and the figure kept
|
||||||
|
// would be too high.
|
||||||
|
const (
|
||||||
|
bandwidthBuffer = 2 << 30
|
||||||
|
minBuffer = 256 << 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bandwidth is how fast this machine's memory reads, in GB/s: with every
|
||||||
|
// CPU reading, and with one alone. A replica given a few threads of a large
|
||||||
|
// machine reads at a few times the second, not at the first: on a server of
|
||||||
|
// many memory channels one core cannot keep more than a fraction of them
|
||||||
|
// busy.
|
||||||
|
type Bandwidth struct {
|
||||||
|
All, Core float64
|
||||||
|
}
|
||||||
|
|
||||||
|
// coreBuffer is what the one-core passes read: past any cache, and short
|
||||||
|
// enough that one core reads it in a fraction of a second.
|
||||||
|
const coreBuffer = 512 << 20
|
||||||
|
|
||||||
|
// MeasureMemoryBandwidth is the memory's read rate, the best of a few passes
|
||||||
|
// each way; zero when the machine has too little free memory to measure.
|
||||||
|
func MeasureMemoryBandwidth() Bandwidth {
|
||||||
|
size := int64(bandwidthBuffer)
|
||||||
|
// Never more than a quarter of what is free: a node already holding
|
||||||
|
// models must not be pushed into swap by a measurement. Without room
|
||||||
|
// for a buffer past the cache, none is measured: the hourly retry
|
||||||
|
// measures it when there is.
|
||||||
|
if avail := memAvailable("/proc/meminfo"); avail > 0 {
|
||||||
|
size = min(size, avail/4)
|
||||||
|
}
|
||||||
|
if size < max(minBuffer, 8*lastCacheBytes("/sys/devices/system/cpu")) {
|
||||||
|
return Bandwidth{}
|
||||||
|
}
|
||||||
|
buf := make([]uint64, size/8)
|
||||||
|
for i := range buf {
|
||||||
|
buf[i] = uint64(i)
|
||||||
|
}
|
||||||
|
workers := runtime.NumCPU()
|
||||||
|
chunk := (len(buf) + workers - 1) / workers
|
||||||
|
best := 0.0
|
||||||
|
for pass := 0; pass < 4; pass++ {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
start := time.Now()
|
||||||
|
for w := 0; w < workers; w++ {
|
||||||
|
lo, hi := w*chunk, min((w+1)*chunk, len(buf))
|
||||||
|
if lo >= hi {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
go func(part []uint64) {
|
||||||
|
defer wg.Done()
|
||||||
|
sink(sum(part))
|
||||||
|
}(buf[lo:hi])
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if s := time.Since(start).Seconds(); s > 0 {
|
||||||
|
best = max(best, float64(size)/s/1e9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// And one core alone, over a part of the same buffer.
|
||||||
|
part := buf[:min(len(buf), coreBuffer/8)]
|
||||||
|
core := 0.0
|
||||||
|
for pass := 0; pass < 3; pass++ {
|
||||||
|
start := time.Now()
|
||||||
|
sink(sum(part))
|
||||||
|
if s := time.Since(start).Seconds(); s > 0 {
|
||||||
|
core = max(core, float64(len(part)*8)/s/1e9)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Bandwidth{All: best, Core: min(core, best)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sum reads every word, eight at a time so the loop is not what limits it.
|
||||||
|
func sum(p []uint64) uint64 {
|
||||||
|
var a, b, c, d, e, f, g, h uint64
|
||||||
|
i := 0
|
||||||
|
for ; i+8 <= len(p); i += 8 {
|
||||||
|
a += p[i]
|
||||||
|
b += p[i+1]
|
||||||
|
c += p[i+2]
|
||||||
|
d += p[i+3]
|
||||||
|
e += p[i+4]
|
||||||
|
f += p[i+5]
|
||||||
|
g += p[i+6]
|
||||||
|
h += p[i+7]
|
||||||
|
}
|
||||||
|
for ; i < len(p); i++ {
|
||||||
|
a += p[i]
|
||||||
|
}
|
||||||
|
return a + b + c + d + e + f + g + h
|
||||||
|
}
|
||||||
|
|
||||||
|
// sinkValue keeps the sums alive, so the reads are not optimised away.
|
||||||
|
var sinkValue struct {
|
||||||
|
sync.Mutex
|
||||||
|
v uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
func sink(v uint64) {
|
||||||
|
sinkValue.Lock()
|
||||||
|
sinkValue.v += v
|
||||||
|
sinkValue.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// memAvailable is MemAvailable in bytes, 0 when unknown.
|
||||||
|
func memAvailable(path string) int64 {
|
||||||
|
f, err := os.Open(path) //nolint:gosec // a fixed system path
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
sc := bufio.NewScanner(f)
|
||||||
|
for sc.Scan() {
|
||||||
|
fields := strings.Fields(sc.Text())
|
||||||
|
if len(fields) >= 2 && fields[0] == "MemAvailable:" {
|
||||||
|
kb, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||||
|
return kb << 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// lastCacheBytes is the largest cache any CPU reports, 0 when unknown.
|
||||||
|
func lastCacheBytes(root string) int64 {
|
||||||
|
paths, _ := filepath.Glob(filepath.Join(root, "cpu*", "cache", "index*", "size"))
|
||||||
|
var most int64
|
||||||
|
for _, p := range paths {
|
||||||
|
b, err := os.ReadFile(p) //nolint:gosec // under /sys
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v := strings.TrimSpace(string(b))
|
||||||
|
mult := int64(1)
|
||||||
|
switch {
|
||||||
|
case strings.HasSuffix(v, "K"):
|
||||||
|
mult, v = 1<<10, strings.TrimSuffix(v, "K")
|
||||||
|
case strings.HasSuffix(v, "M"):
|
||||||
|
mult, v = 1<<20, strings.TrimSuffix(v, "M")
|
||||||
|
}
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||||
|
most = max(most, n*mult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return most
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMemoryBandwidthIsMeasured(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("reads half a gigabyte")
|
||||||
|
}
|
||||||
|
bw := MeasureMemoryBandwidth()
|
||||||
|
// Any machine running this reads more than a gigabyte a second and less
|
||||||
|
// than a few terabytes, and one core no more than all of them.
|
||||||
|
if bw.All < 1 || bw.All > 5000 || bw.Core < 0.5 || bw.Core > bw.All {
|
||||||
|
t.Errorf("measured %.1f GB/s, %.1f on one core", bw.All, bw.Core)
|
||||||
|
}
|
||||||
|
t.Logf("%.1f GB/s, %.1f on one core", bw.All, bw.Core)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheLastCacheIsReadFromSys(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for path, size := range map[string]string{"cpu0/cache/index0/size": "48K", "cpu0/cache/index3/size": "96M"} {
|
||||||
|
p := filepath.Join(root, path)
|
||||||
|
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||||
|
_ = os.WriteFile(p, []byte(size+"\n"), 0o644)
|
||||||
|
}
|
||||||
|
if got := lastCacheBytes(root); got != 96<<20 {
|
||||||
|
t.Errorf("%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/csv"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A card in the wrong slot: an x16 card in a slot wired x8 or x4, behind a
|
||||||
|
// riser, or in a slot of an older PCIe generation than the card's, links
|
||||||
|
// slower than it was built to, and nothing says so. A model on the card
|
||||||
|
// still decodes at the card's own memory speed, but it loads slower, a
|
||||||
|
// tensor-parallel replica over PCIe is slower, and so is anything that
|
||||||
|
// spills to the host. The driver says how the link stands and what the
|
||||||
|
// card could do; the difference is reported where the node's warnings are.
|
||||||
|
|
||||||
|
// PCIeLink is how a card is linked to its host.
|
||||||
|
type PCIeLink struct {
|
||||||
|
// Gen is the fastest generation the card and the slot together allow —
|
||||||
|
// not the current one, which drops while the card idles, to save power.
|
||||||
|
// GPUGen is the card's own fastest, 0 when the driver does not say.
|
||||||
|
Gen int `json:"gen,omitempty"`
|
||||||
|
GPUGen int `json:"gpu_gen,omitempty"`
|
||||||
|
// Width is the lanes in use now, WidthMax the card's most.
|
||||||
|
Width int `json:"width,omitempty"`
|
||||||
|
WidthMax int `json:"width_max,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// laneGBs is one lane's usable rate in GB/s, by generation.
|
||||||
|
var laneGBs = map[int]float64{1: 0.25, 2: 0.5, 3: 0.985, 4: 1.969, 5: 3.938, 6: 7.877}
|
||||||
|
|
||||||
|
// GBs is what a link of gen and width carries, in GB/s each way.
|
||||||
|
func pcieGBs(gen, width int) float64 { return laneGBs[gen] * float64(width) }
|
||||||
|
|
||||||
|
// Problem says how a card's link falls short of what the card could do,
|
||||||
|
// empty when it does not or the driver did not say.
|
||||||
|
func (p PCIeLink) Problem() string {
|
||||||
|
gen := p.Gen
|
||||||
|
if p.GPUGen > 0 && gen == 0 {
|
||||||
|
gen = p.GPUGen
|
||||||
|
}
|
||||||
|
slowGen := p.GPUGen > 0 && p.Gen > 0 && p.Gen < p.GPUGen
|
||||||
|
narrow := p.WidthMax > 0 && p.Width > 0 && p.Width < p.WidthMax
|
||||||
|
if !slowGen && !narrow {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
could := pcieGBs(max(p.GPUGen, gen), p.WidthMax)
|
||||||
|
has := pcieGBs(gen, p.Width)
|
||||||
|
var why []string
|
||||||
|
if narrow {
|
||||||
|
why = append(why, fmt.Sprintf("x%d of its x%d lanes", p.Width, p.WidthMax))
|
||||||
|
}
|
||||||
|
if slowGen {
|
||||||
|
why = append(why, fmt.Sprintf("PCIe Gen%d of its Gen%d", p.Gen, p.GPUGen))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("linked at %s: %.1f GB/s where it could have %.1f. "+
|
||||||
|
"Check the slot (wired fewer lanes, or an older generation), a riser, or the BIOS's PCIe setting",
|
||||||
|
strings.Join(why, " and "), has, could)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pcieFields are asked apart from the rest, and the card's own fastest
|
||||||
|
// generation apart again: a driver that does not know a field refuses the
|
||||||
|
// whole query, and these must not cost a node its cards.
|
||||||
|
var pcieFields = [][]string{
|
||||||
|
{"index", "pcie.link.gen.max", "pcie.link.width.current", "pcie.link.width.max", "pcie.link.gen.gpumax"},
|
||||||
|
{"index", "pcie.link.gen.max", "pcie.link.width.current", "pcie.link.width.max"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// readPCIe fills each card's link from nvidia-smi, leaving it empty when
|
||||||
|
// the driver says nothing of it.
|
||||||
|
func readPCIe(ctx context.Context, binary string, cards []GPU) {
|
||||||
|
for _, fields := range pcieFields {
|
||||||
|
out, err := exec.CommandContext(ctx, binary, "--query-gpu="+strings.Join(fields, ","),
|
||||||
|
"--format=csv,noheader,nounits").Output()
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
links := parsePCIe(string(out))
|
||||||
|
for i := range cards {
|
||||||
|
if l, ok := links[cards[i].Index]; ok && cards[i].MIG == nil {
|
||||||
|
cards[i].PCIe = l
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
for i := range cards {
|
||||||
|
if cards[i].MIG == nil {
|
||||||
|
cards[i].PCIe = withSysfs(cards[i].PCIe, "/sys/bus/pci/devices", cards[i].PCIBusID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// withSysfs takes the card's own lane count, and its own fastest
|
||||||
|
// generation, from the kernel: the driver's width.max is what card and slot
|
||||||
|
// allow together, so a x16 card in a slot wired x8 reads x8 of x8 there,
|
||||||
|
// while the device's max_link_width is the card's, 16.
|
||||||
|
func withSysfs(l PCIeLink, root, busID string) PCIeLink {
|
||||||
|
dev := sysfsBusID(busID)
|
||||||
|
if dev == "" {
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
read := func(name string) string {
|
||||||
|
b, err := os.ReadFile(filepath.Join(root, dev, name)) //nolint:gosec // under /sys
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(read("max_link_width")); err == nil && n > 0 {
|
||||||
|
l.WidthMax = n
|
||||||
|
}
|
||||||
|
if n, err := strconv.Atoi(read("current_link_width")); err == nil && n > 0 {
|
||||||
|
l.Width = n
|
||||||
|
}
|
||||||
|
if g := genOfSpeed(read("max_link_speed")); g > 0 && l.GPUGen == 0 {
|
||||||
|
l.GPUGen = g
|
||||||
|
}
|
||||||
|
return l
|
||||||
|
}
|
||||||
|
|
||||||
|
// sysfsBusID is nvidia-smi's 00000000:3B:00.0 as the kernel names it,
|
||||||
|
// 0000:3b:00.0.
|
||||||
|
func sysfsBusID(id string) string {
|
||||||
|
id = strings.ToLower(strings.TrimSpace(id))
|
||||||
|
dom, rest, ok := strings.Cut(id, ":")
|
||||||
|
if !ok || len(dom) < 4 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return dom[len(dom)-4:] + ":" + rest
|
||||||
|
}
|
||||||
|
|
||||||
|
// genOfSpeed is the PCIe generation of a link speed as sysfs writes it.
|
||||||
|
func genOfSpeed(s string) int {
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(s, "2.5 GT/s"):
|
||||||
|
return 1
|
||||||
|
case strings.HasPrefix(s, "5.0 GT/s"), strings.HasPrefix(s, "5 GT/s"):
|
||||||
|
return 2
|
||||||
|
case strings.HasPrefix(s, "8.0 GT/s"), strings.HasPrefix(s, "8 GT/s"):
|
||||||
|
return 3
|
||||||
|
case strings.HasPrefix(s, "16.0 GT/s"), strings.HasPrefix(s, "16 GT/s"):
|
||||||
|
return 4
|
||||||
|
case strings.HasPrefix(s, "32.0 GT/s"), strings.HasPrefix(s, "32 GT/s"):
|
||||||
|
return 5
|
||||||
|
case strings.HasPrefix(s, "64.0 GT/s"), strings.HasPrefix(s, "64 GT/s"):
|
||||||
|
return 6
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func parsePCIe(out string) map[int]PCIeLink {
|
||||||
|
r := csv.NewReader(strings.NewReader(out))
|
||||||
|
r.TrimLeadingSpace = true
|
||||||
|
r.FieldsPerRecord = -1
|
||||||
|
rows, _ := r.ReadAll()
|
||||||
|
num := func(s string) int {
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
links := map[int]PCIeLink{}
|
||||||
|
for _, row := range rows {
|
||||||
|
if len(row) < 4 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(strings.TrimSpace(row[0]))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
l := PCIeLink{Gen: num(row[1]), Width: num(row[2]), WidthMax: num(row[3])}
|
||||||
|
if len(row) > 4 {
|
||||||
|
l.GPUGen = num(row[4])
|
||||||
|
}
|
||||||
|
links[idx] = l
|
||||||
|
}
|
||||||
|
return links
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestACardInTheWrongSlotIsSaidSo(t *testing.T) {
|
||||||
|
for _, c := range []struct {
|
||||||
|
link PCIeLink
|
||||||
|
want string // empty: nothing to say
|
||||||
|
}{
|
||||||
|
// The PoC's Quadro RTX 6000, idle: current Gen1, but its fastest is 3,
|
||||||
|
// as the card's is, on all 16 lanes. Nothing wrong.
|
||||||
|
{PCIeLink{Gen: 3, GPUGen: 3, Width: 16, WidthMax: 16}, ""},
|
||||||
|
// An x16 card in a slot wired x8.
|
||||||
|
{PCIeLink{Gen: 4, GPUGen: 4, Width: 8, WidthMax: 16}, "x8 of its x16 lanes: 15.8 GB/s where it could have 31.5"},
|
||||||
|
// A Gen5 card in a Gen4 slot.
|
||||||
|
{PCIeLink{Gen: 4, GPUGen: 5, Width: 16, WidthMax: 16}, "PCIe Gen4 of its Gen5: 31.5 GB/s where it could have 63.0"},
|
||||||
|
// Both, behind a riser.
|
||||||
|
{PCIeLink{Gen: 3, GPUGen: 4, Width: 4, WidthMax: 16}, "x4 of its x16 lanes and PCIe Gen3 of its Gen4"},
|
||||||
|
// A driver that says nothing.
|
||||||
|
{PCIeLink{}, ""},
|
||||||
|
} {
|
||||||
|
got := c.link.Problem()
|
||||||
|
if (c.want == "") != (got == "") || !strings.Contains(got, c.want) {
|
||||||
|
t.Errorf("%+v: %q, want %q", c.link, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheLinkIsReadFromNvidiaSMI(t *testing.T) {
|
||||||
|
links := parsePCIe("0, 3, 16, 16, 3\n1, 4, 8, 16, [N/A]\n")
|
||||||
|
if links[0] != (PCIeLink{Gen: 3, GPUGen: 3, Width: 16, WidthMax: 16}) || links[1] != (PCIeLink{Gen: 4, Width: 8, WidthMax: 16}) {
|
||||||
|
t.Errorf("%+v", links)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTheCardsOwnLanesComeFromTheKernel(t *testing.T) {
|
||||||
|
// The driver says x8 of x8 for a x16 card in a slot wired x8; the
|
||||||
|
// device's own max_link_width says 16.
|
||||||
|
root := t.TempDir()
|
||||||
|
dev := filepath.Join(root, "0000:3b:00.0")
|
||||||
|
_ = os.MkdirAll(dev, 0o755)
|
||||||
|
for f, v := range map[string]string{"max_link_width": "16", "current_link_width": "8", "max_link_speed": "16.0 GT/s PCIe"} {
|
||||||
|
_ = os.WriteFile(filepath.Join(dev, f), []byte(v+"\n"), 0o644)
|
||||||
|
}
|
||||||
|
l := withSysfs(PCIeLink{Gen: 4, Width: 8, WidthMax: 8}, root, "00000000:3B:00.0")
|
||||||
|
if l.WidthMax != 16 || l.Width != 8 || l.GPUGen != 4 || !strings.Contains(l.Problem(), "x8 of its x16") {
|
||||||
|
t.Errorf("%+v: %q", l, l.Problem())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GPUProfile is how much of a card's chip the work on it uses. nvidia-smi's
|
||||||
|
// utilization.gpu cannot say: it is the share of time any kernel ran, and a
|
||||||
|
// kernel keeping one SM of a hundred busy reads 100%. Measured on a Quadro
|
||||||
|
// RTX 6000 under decode, it read 92% while the SMs had work 69% of the time
|
||||||
|
// and issued an instruction on 18% of their cycles.
|
||||||
|
//
|
||||||
|
// These come from the card's profiling counters, which only two things read
|
||||||
|
// without stopping the work: NVML's GPM, on Hopper and later, and DCGM, which
|
||||||
|
// a site may run as dcgm-exporter. Percentages; -1 is not reported.
|
||||||
|
type GPUProfile struct {
|
||||||
|
// SMActive is the share of time the SMs had work, averaged over all of
|
||||||
|
// them; SMOccupancy how full those SMs were with warps.
|
||||||
|
SMActive, SMOccupancy float64
|
||||||
|
// TensorActive is the tensor cores. Decode at batch one hardly uses them
|
||||||
|
// even when nothing is wrong: a quantised matrix-vector product runs on
|
||||||
|
// the integer units. Prefill and batches are where they show.
|
||||||
|
TensorActive float64
|
||||||
|
// DRAMActive is the memory interface, which decode is bound by.
|
||||||
|
DRAMActive float64
|
||||||
|
// Source is "gpm" or "dcgm".
|
||||||
|
Source string
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultDCGMExporter is where dcgm-exporter listens unless told otherwise.
|
||||||
|
const DefaultDCGMExporter = "http://127.0.0.1:9400/metrics"
|
||||||
|
|
||||||
|
// dcgmRetry is how long a dcgm-exporter that did not answer is left alone.
|
||||||
|
// Most nodes run none, and asking every ten seconds would be asking nobody.
|
||||||
|
const dcgmRetry = 5 * time.Minute
|
||||||
|
|
||||||
|
// gpmSource reads GPM, where this build and the card have it.
|
||||||
|
type gpmSource interface {
|
||||||
|
read() map[int]GPUProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProfSampler reads each card's GPUProfile: from GPM where the card has it,
|
||||||
|
// otherwise from a dcgm-exporter on the node.
|
||||||
|
type ProfSampler struct {
|
||||||
|
gpm gpmSource
|
||||||
|
dcgm string
|
||||||
|
get func(ctx context.Context, url string) ([]byte, error)
|
||||||
|
now func() time.Time
|
||||||
|
|
||||||
|
dcgmAfter time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewProfSampler reads this machine. dcgmURL is the dcgm-exporter to ask:
|
||||||
|
// empty is DefaultDCGMExporter, "off" asks none.
|
||||||
|
func NewProfSampler(dcgmURL string) *ProfSampler {
|
||||||
|
switch dcgmURL {
|
||||||
|
case "":
|
||||||
|
dcgmURL = DefaultDCGMExporter
|
||||||
|
case "off":
|
||||||
|
dcgmURL = ""
|
||||||
|
}
|
||||||
|
return &ProfSampler{gpm: newGPM(), dcgm: dcgmURL, get: httpGet, now: time.Now}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample is each card's profile by index; a card missing has none. GPM needs
|
||||||
|
// two samples, so its cards appear from the second call.
|
||||||
|
func (p *ProfSampler) Sample(ctx context.Context) map[int]GPUProfile {
|
||||||
|
out := map[int]GPUProfile{}
|
||||||
|
if p.gpm != nil {
|
||||||
|
for i, v := range p.gpm.read() {
|
||||||
|
out[i] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p.dcgm == "" || p.now().Before(p.dcgmAfter) {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
body, err := p.get(ctx, p.dcgm)
|
||||||
|
if err != nil {
|
||||||
|
p.dcgmAfter = p.now().Add(dcgmRetry)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for i, v := range parseDCGM(body) {
|
||||||
|
if _, ok := out[i]; !ok {
|
||||||
|
out[i] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpGet(ctx context.Context, url string) ([]byte, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, io.ErrUnexpectedEOF
|
||||||
|
}
|
||||||
|
return io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dcgm-exporter fields read, as ratios of one. Its default field list
|
||||||
|
// has them on every card DCGM can profile.
|
||||||
|
var dcgmFields = map[string]func(*GPUProfile, float64){
|
||||||
|
"DCGM_FI_PROF_SM_ACTIVE": func(p *GPUProfile, v float64) { p.SMActive = v },
|
||||||
|
"DCGM_FI_PROF_SM_OCCUPANCY": func(p *GPUProfile, v float64) { p.SMOccupancy = v },
|
||||||
|
"DCGM_FI_PROF_PIPE_TENSOR_ACTIVE": func(p *GPUProfile, v float64) { p.TensorActive = v },
|
||||||
|
"DCGM_FI_PROF_DRAM_ACTIVE": func(p *GPUProfile, v float64) { p.DRAMActive = v },
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseDCGM reads dcgm-exporter's text exposition: a line per field and
|
||||||
|
// card, the card in the gpu label. A card with none of the fields — DCGM
|
||||||
|
// cannot profile it — is left out. A MIG instance's lines carry a
|
||||||
|
// GPU_I_ID and are left out too: these are whole cards.
|
||||||
|
func parseDCGM(body []byte) map[int]GPUProfile {
|
||||||
|
out := map[int]GPUProfile{}
|
||||||
|
for _, line := range strings.Split(string(body), "\n") {
|
||||||
|
if line == "" || line[0] == '#' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
open := strings.IndexByte(line, '{')
|
||||||
|
end := strings.LastIndexByte(line, '}')
|
||||||
|
if open < 0 || end < open {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
set, ok := dcgmFields[line[:open]]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
labels := line[open+1 : end]
|
||||||
|
if strings.Contains(labels, `GPU_I_ID="`) && !strings.Contains(labels, `GPU_I_ID=""`) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx, err := strconv.Atoi(labelValue(labels, "gpu"))
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
val := strings.Fields(line[end+1:])
|
||||||
|
if len(val) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(val[0], 64)
|
||||||
|
if err != nil || v < 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
p, seen := out[idx]
|
||||||
|
if !seen {
|
||||||
|
p = GPUProfile{SMActive: -1, SMOccupancy: -1, TensorActive: -1, DRAMActive: -1, Source: "dcgm"}
|
||||||
|
}
|
||||||
|
set(&p, 100*v)
|
||||||
|
out[idx] = p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// labelValue is one label's value from a Prometheus label set.
|
||||||
|
func labelValue(labels, name string) string {
|
||||||
|
for _, kv := range strings.Split(labels, ",") {
|
||||||
|
k, v, ok := strings.Cut(strings.TrimSpace(kv), "=")
|
||||||
|
if ok && k == name {
|
||||||
|
return strings.Trim(v, `"`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package discovery
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// dcgmBody is dcgm-exporter's output for two cards and a MIG instance:
|
||||||
|
// card 0 profiled, card 1 a model DCGM cannot profile, which has the device
|
||||||
|
// fields and none of the PROF ones.
|
||||||
|
const dcgmBody = `# HELP DCGM_FI_DEV_GPU_UTIL GPU utilization (in %).
|
||||||
|
# TYPE DCGM_FI_DEV_GPU_UTIL gauge
|
||||||
|
DCGM_FI_DEV_GPU_UTIL{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 100
|
||||||
|
DCGM_FI_DEV_GPU_UTIL{gpu="1",UUID="GPU-b",device="nvidia1",modelName="Quadro RTX 6000",Hostname="n1"} 92
|
||||||
|
# HELP DCGM_FI_PROF_SM_ACTIVE The ratio of cycles an SM has at least 1 warp assigned.
|
||||||
|
# TYPE DCGM_FI_PROF_SM_ACTIVE gauge
|
||||||
|
DCGM_FI_PROF_SM_ACTIVE{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 0.007042
|
||||||
|
DCGM_FI_PROF_SM_ACTIVE{gpu="2",UUID="GPU-c",device="nvidia2",modelName="NVIDIA A100",GPU_I_PROFILE="3g.40gb",GPU_I_ID="1",Hostname="n1"} 0.5
|
||||||
|
DCGM_FI_PROF_SM_OCCUPANCY{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 0.25
|
||||||
|
DCGM_FI_PROF_PIPE_TENSOR_ACTIVE{gpu="0",UUID="GPU-a",device="nvidia0",modelName="NVIDIA L40S",Hostname="n1"} 0
|
||||||
|
`
|
||||||
|
|
||||||
|
func TestDCGMExporterSaysHowMuchOfEachChipIsInUse(t *testing.T) {
|
||||||
|
got := parseDCGM([]byte(dcgmBody))
|
||||||
|
if len(got) != 1 {
|
||||||
|
t.Fatalf("want card 0 alone (card 1 unprofiled, card 2 a MIG instance): %+v", got)
|
||||||
|
}
|
||||||
|
p := got[0]
|
||||||
|
// The LinkedIn L40S: 100% busy, one SM of 142 at work.
|
||||||
|
if p.SMActive < 0.70 || p.SMActive > 0.71 || p.SMOccupancy != 25 || p.TensorActive != 0 || p.Source != "dcgm" {
|
||||||
|
t.Errorf("card 0 %+v", p)
|
||||||
|
}
|
||||||
|
// A field the exporter does not send is unknown, not zero.
|
||||||
|
if p.DRAMActive != -1 {
|
||||||
|
t.Errorf("DRAM active not sent, read as %v", p.DRAMActive)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeGPM map[int]GPUProfile
|
||||||
|
|
||||||
|
func (f fakeGPM) read() map[int]GPUProfile { return f }
|
||||||
|
|
||||||
|
func TestGPMIsPreferredAndDCGMFillsTheCardsItDoesNotCover(t *testing.T) {
|
||||||
|
calls := 0
|
||||||
|
p := &ProfSampler{
|
||||||
|
gpm: fakeGPM{1: {SMActive: 40, Source: "gpm"}},
|
||||||
|
dcgm: DefaultDCGMExporter,
|
||||||
|
get: func(context.Context, string) ([]byte, error) {
|
||||||
|
calls++
|
||||||
|
return []byte(`DCGM_FI_PROF_SM_ACTIVE{gpu="0"} 0.5` + "\n" + `DCGM_FI_PROF_SM_ACTIVE{gpu="1"} 0.9` + "\n"), nil
|
||||||
|
},
|
||||||
|
now: time.Now,
|
||||||
|
}
|
||||||
|
got := p.Sample(t.Context())
|
||||||
|
if got[0].Source != "dcgm" || got[0].SMActive != 50 {
|
||||||
|
t.Errorf("card 0 %+v", got[0])
|
||||||
|
}
|
||||||
|
if got[1].Source != "gpm" || got[1].SMActive != 40 {
|
||||||
|
t.Errorf("GPM's card was taken from DCGM: %+v", got[1])
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Errorf("%d calls", calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestANodeWithoutDCGMIsNotAskedEverySample(t *testing.T) {
|
||||||
|
now := time.Now()
|
||||||
|
calls := 0
|
||||||
|
p := &ProfSampler{dcgm: DefaultDCGMExporter, now: func() time.Time { return now },
|
||||||
|
get: func(context.Context, string) ([]byte, error) { calls++; return nil, errors.New("connection refused") }}
|
||||||
|
for range 5 {
|
||||||
|
if got := p.Sample(t.Context()); len(got) != 0 {
|
||||||
|
t.Fatalf("%+v", got)
|
||||||
|
}
|
||||||
|
now = now.Add(10 * time.Second)
|
||||||
|
}
|
||||||
|
if calls != 1 {
|
||||||
|
t.Errorf("asked %d times in 50 s", calls)
|
||||||
|
}
|
||||||
|
now = now.Add(dcgmRetry)
|
||||||
|
p.Sample(t.Context())
|
||||||
|
if calls != 2 {
|
||||||
|
t.Errorf("not asked again after %v", dcgmRetry)
|
||||||
|
}
|
||||||
|
if off := NewProfSampler("off"); off.dcgm != "" {
|
||||||
|
t.Errorf("off still asks %q", off.dcgm)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEachSampleCarriesTheProfileOfTheCardsThatHaveOne(t *testing.T) {
|
||||||
|
smi := func(context.Context, string) ([]byte, error) {
|
||||||
|
return []byte("0, 71, 239, 260, 0x0, 92, 30, 23000, 24576, 1878, 0, 0\n" +
|
||||||
|
"1, 30, 20, 260, 0x0, 0, 0, 512, 24576, 300, 0, 0\n"), nil
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
s := sampler(t.TempDir(), smi, &now).WithProfile(&ProfSampler{
|
||||||
|
gpm: fakeGPM{0: {SMActive: 69, SMOccupancy: 45, TensorActive: 1, DRAMActive: 33, Source: "gpm"}}, now: time.Now})
|
||||||
|
got := s.Sample(t.Context()).GPUs
|
||||||
|
if got[0].Profile == nil || got[0].Profile.SMActive != 69 || got[0].UtilPercent != 92 {
|
||||||
|
t.Errorf("card 0 %+v", got[0])
|
||||||
|
}
|
||||||
|
if got[1].Profile != nil {
|
||||||
|
t.Errorf("card 1 has a profile nobody read: %+v", got[1].Profile)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,6 +49,9 @@ type GPUSensor struct {
|
|||||||
MemoryUsedMB, MemoryTotalMB float64
|
MemoryUsedMB, MemoryTotalMB float64
|
||||||
SMClockMHz float64
|
SMClockMHz float64
|
||||||
ECCUncorrected float64
|
ECCUncorrected float64
|
||||||
|
// Profile is how much of the chip the work uses, where something on the
|
||||||
|
// node can say: nil on most cards. UtilPercent is not that.
|
||||||
|
Profile *GPUProfile
|
||||||
}
|
}
|
||||||
|
|
||||||
// sensorEvery bounds how often nvidia-smi is run. Heartbeats come more often
|
// sensorEvery bounds how often nvidia-smi is run. Heartbeats come more often
|
||||||
@@ -61,6 +64,8 @@ type SensorSampler struct {
|
|||||||
sys string // /sys, or a test's copy of it
|
sys string // /sys, or a test's copy of it
|
||||||
smi func(ctx context.Context, fields string) ([]byte, error)
|
smi func(ctx context.Context, fields string) ([]byte, error)
|
||||||
now func() time.Time
|
now func() time.Time
|
||||||
|
// prof reads the cards' profiling counters; nil reads none.
|
||||||
|
prof *ProfSampler
|
||||||
|
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
last Sensors
|
last Sensors
|
||||||
@@ -74,6 +79,19 @@ func NewSensorSampler() *SensorSampler {
|
|||||||
return &SensorSampler{sys: "/sys", smi: runSMI, now: time.Now}
|
return &SensorSampler{sys: "/sys", smi: runSMI, now: time.Now}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithProfile has each sample carry the cards' profiles as well.
|
||||||
|
func (s *SensorSampler) WithProfile(p *ProfSampler) *SensorSampler {
|
||||||
|
s.prof = p
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// SampleGPUs reads every card's heat, power and how busy it is now, without
|
||||||
|
// a sampler's ten seconds of memory: for a screen that asks every few seconds
|
||||||
|
// and wants what it asked for. Nil on a machine without nvidia-smi or a card.
|
||||||
|
func SampleGPUs(ctx context.Context) []GPUSensor {
|
||||||
|
return (&SensorSampler{smi: runSMI}).gpus(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func runSMI(ctx context.Context, fields string) ([]byte, error) {
|
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()
|
||||||
@@ -94,6 +112,14 @@ func (s *SensorSampler) Sample(ctx context.Context) Sensors {
|
|||||||
out.CPUTempC, out.InletTempC = s.hwmon()
|
out.CPUTempC, out.InletTempC = s.hwmon()
|
||||||
out.CPUPowerW = s.rapl(now)
|
out.CPUPowerW = s.rapl(now)
|
||||||
out.GPUs = s.gpus(ctx)
|
out.GPUs = s.gpus(ctx)
|
||||||
|
if s.prof != nil {
|
||||||
|
profs := s.prof.Sample(ctx)
|
||||||
|
for i := range out.GPUs {
|
||||||
|
if p, ok := profs[out.GPUs[i].Index]; ok {
|
||||||
|
out.GPUs[i].Profile = &p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
s.last, s.lastAt = out, now
|
s.last, s.lastAt = out, now
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ func (c *ComfyUI) Commands(s ShardSpec) ([]Command, error) {
|
|||||||
// so taken before baseEnv drops them. Without the repository the gate
|
// 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
|
// links no Hugging Face cache, and sf3d's node went to the network for
|
||||||
// its gated repository and was refused with a 401.
|
// its gated repository and was refused with a 401.
|
||||||
for _, k := range []string{comfyNodesEnv, comfyHFRepoEnv} {
|
for _, k := range []string{comfyNodesEnv, comfyHFRepoEnv, SharedModelsEnv} {
|
||||||
if v := p.Env[k]; v != "" {
|
if v := p.Env[k]; v != "" {
|
||||||
env[k] = v
|
env[k] = v
|
||||||
}
|
}
|
||||||
@@ -144,3 +144,8 @@ const comfyNodesEnv = "LLMGW_COMFYUI_NODES"
|
|||||||
// comfyHFRepoEnv is the repository a custom node loads the weights by name
|
// comfyHFRepoEnv is the repository a custom node loads the weights by name
|
||||||
// from; catalog.HFRepoEnv and comfy.HFRepoEnv by the same name.
|
// from; catalog.HFRepoEnv and comfy.HFRepoEnv by the same name.
|
||||||
const comfyHFRepoEnv = "LLMGW_COMFYUI_HF_REPO"
|
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"
|
||||||
@@ -47,6 +47,13 @@ type Profile struct {
|
|||||||
// KVEvents has the engine publish its KV cache events to the agent,
|
// KVEvents has the engine publish its KV cache events to the agent,
|
||||||
// for the router's precise index. vLLM only.
|
// for the router's precise index. vLLM only.
|
||||||
KVEvents bool `json:"kv_events,omitempty"`
|
KVEvents bool `json:"kv_events,omitempty"`
|
||||||
|
// AllowPickle lets the node fetch weights in a pickle format, which run
|
||||||
|
// code when loaded; the catalog's allow_pickle.
|
||||||
|
AllowPickle bool `json:"allow_pickle,omitempty"`
|
||||||
|
// ModelSHA256 is the sha256 the registry published for the file
|
||||||
|
// ModelPath names; the node refuses a copy that is not it. Empty when
|
||||||
|
// nothing is known to check against.
|
||||||
|
ModelSHA256 string `json:"model_sha256,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ShardSpec is one shard of one replica on this node.
|
// ShardSpec is one shard of one replica on this node.
|
||||||
@@ -215,6 +222,8 @@ func NewRegistry(cfg Config) *Registry {
|
|||||||
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})
|
r.Add(&ComfyUI{cfg: cfg.ComfyUI})
|
||||||
|
r.Add(&Strata{cfg: cfg.Strata})
|
||||||
|
r.Add(&FreeToken{cfg: cfg.FreeToken})
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,6 +274,13 @@ type Config struct {
|
|||||||
Sim SimConfig `koanf:"sim"`
|
Sim SimConfig `koanf:"sim"`
|
||||||
// ComfyUI makes pictures; offered by a node whose ComfyUI is named here.
|
// ComfyUI makes pictures; offered by a node whose ComfyUI is named here.
|
||||||
ComfyUI ComfyUIConfig `koanf:"comfyui"`
|
ComfyUI ComfyUIConfig `koanf:"comfyui"`
|
||||||
|
// Strata serves a few mixture-of-experts models from RAM and one card;
|
||||||
|
// offered by a node whose Strata is named here, or that installed it.
|
||||||
|
Strata StrataConfig `koanf:"strata"`
|
||||||
|
// FreeToken serves mixture-of-experts models larger than a card from
|
||||||
|
// RAM and one card of 8.0 or newer; installed by the agent where the
|
||||||
|
// cards allow it, or named here.
|
||||||
|
FreeToken FreeTokenConfig `koanf:"freetoken"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- shared helpers ---
|
// --- shared helpers ---
|
||||||
|
|||||||
@@ -477,14 +477,14 @@ 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{"comfyui", "llamacpp", "sglang", "sim", "trtllm", "vllm"}) {
|
if got := r.Names(); !slices.Equal(got, []string{"comfyui", "freetoken", "llamacpp", "sglang", "sim", "strata", "trtllm", "vllm"}) {
|
||||||
t.Errorf("registry contents: %v", got)
|
t.Errorf("registry contents: %v", got)
|
||||||
}
|
}
|
||||||
if _, err := r.Get("vllm"); err != nil {
|
if _, err := r.Get("vllm"); err != nil {
|
||||||
t.Errorf("vllm missing: %v", err)
|
t.Errorf("vllm missing: %v", err)
|
||||||
}
|
}
|
||||||
err := func() error { _, err := r.Get("nope"); return err }()
|
err := func() error { _, err := r.Get("nope"); return err }()
|
||||||
if err == nil || !strings.Contains(err.Error(), "llamacpp, sglang, sim, trtllm, vllm") {
|
if err == nil || !strings.Contains(err.Error(), "llamacpp, sglang, sim, strata, trtllm, vllm") {
|
||||||
t.Errorf("an unknown engine should list what is available: %v", err)
|
t.Errorf("an unknown engine should list what is available: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package engines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FreeTokenConfig locates a FreeToken installation.
|
||||||
|
type FreeTokenConfig struct {
|
||||||
|
// Binary is its ft command, in the environment it was installed into.
|
||||||
|
// The agent installs one itself on a node whose GPUs it runs on; this
|
||||||
|
// is for one installed by hand.
|
||||||
|
Binary string `koanf:"binary"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// freetokenToolkit finds the CUDA 13 toolkit; a variable for tests.
|
||||||
|
var freetokenToolkit = func() string { return discovery.CUDAToolkit(13) }
|
||||||
|
|
||||||
|
// FreeToken runs FreeToken (github.com/FlashML-org/FreeToken): a
|
||||||
|
// mixture-of-experts engine that keeps a model's experts in host RAM, the
|
||||||
|
// ones used most in VRAM, and streams the rest over PCIe, so a model several
|
||||||
|
// times a card's size serves from one card. It reads Hugging Face
|
||||||
|
// safetensors checkpoints as they are published (FP8, NVFP4, MXFP4, BF16)
|
||||||
|
// and serves the OpenAI and Anthropic APIs. One card of compute capability
|
||||||
|
// 8.0 or newer; it compiles some of its kernels on first start, with the
|
||||||
|
// CUDA 13 toolkit.
|
||||||
|
type FreeToken struct {
|
||||||
|
cfg FreeTokenConfig
|
||||||
|
installed atomic.Pointer[string]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name identifies the engine in the catalog.
|
||||||
|
func (*FreeToken) Name() string { return "freetoken" }
|
||||||
|
|
||||||
|
// HealthPath answers before the model is loaded too, with its progress in
|
||||||
|
// the body; the supervisor waits for the status code, so it counts as ready
|
||||||
|
// only once FreeToken's own startup — loading, warm-up, CUDA graphs — is
|
||||||
|
// over and the API server is accepting.
|
||||||
|
func (*FreeToken) HealthPath() string { return "/health" }
|
||||||
|
|
||||||
|
// MetricsPath is empty: FreeToken serves its numbers as JSON (/v1/stats),
|
||||||
|
// not in Prometheus' text, so the router counts its requests itself.
|
||||||
|
func (*FreeToken) MetricsPath() string { return "" }
|
||||||
|
|
||||||
|
// ServesHTTP is true: a FreeToken replica is one process on one node.
|
||||||
|
func (*FreeToken) ServesHTTP(ShardSpec) bool { return true }
|
||||||
|
|
||||||
|
// UseInstalled points the engine at the ft the agent installed.
|
||||||
|
func (f *FreeToken) UseInstalled(path string) { f.installed.Store(&path) }
|
||||||
|
|
||||||
|
// Configured reports whether the configuration names a FreeToken.
|
||||||
|
func (f *FreeToken) Configured() bool { return f.cfg.Binary != "" }
|
||||||
|
|
||||||
|
func (f *FreeToken) binary() string {
|
||||||
|
if p := f.installed.Load(); p != nil {
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
return f.cfg.Binary
|
||||||
|
}
|
||||||
|
|
||||||
|
// HelpCommand is ft serve's own help.
|
||||||
|
func (f *FreeToken) HelpCommand(Command) (string, []string) {
|
||||||
|
return f.binary(), []string{"serve", "--help"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Probe reports the FreeToken a configuration names, by what it says its
|
||||||
|
// version is.
|
||||||
|
func (f *FreeToken) Probe(ctx context.Context) (string, error) {
|
||||||
|
bin := f.binary()
|
||||||
|
if bin == "" {
|
||||||
|
return "", fmt.Errorf("no FreeToken is configured")
|
||||||
|
}
|
||||||
|
out, err := exec.CommandContext(ctx, bin, "--version").Output() //nolint:gosec // the configured engine
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("%s does not run: %w", bin, err)
|
||||||
|
}
|
||||||
|
// "freetoken version 0.2.1"
|
||||||
|
if fs := strings.Fields(string(out)); len(fs) > 0 {
|
||||||
|
return fs[len(fs)-1], nil
|
||||||
|
}
|
||||||
|
return Unknown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the profile: one card of 8.0 or newer, on one node.
|
||||||
|
// FreeToken has no tensor parallel; it picks one device.
|
||||||
|
func (*FreeToken) Validate(p Profile, gpus []GPUInfo) error {
|
||||||
|
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
|
||||||
|
return fmt.Errorf("profile %q: freetoken runs on one GPU of one node", p.Name)
|
||||||
|
}
|
||||||
|
if err := requireComputeCapability(p, gpus, 8.0, "freetoken"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return validateCommon(p, gpus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands is ft serve. The card is the shard's, by CUDA_VISIBLE_DEVICES,
|
||||||
|
// so it is device 0 to FreeToken; its key goes in the environment, where it
|
||||||
|
// reads it from, rather than on a command line every user can read.
|
||||||
|
func (f *FreeToken) Commands(sh ShardSpec) ([]Command, error) {
|
||||||
|
bin := f.binary()
|
||||||
|
if bin == "" {
|
||||||
|
return nil, fmt.Errorf("freetoken: no FreeToken is installed or configured (engines.freetoken.binary)")
|
||||||
|
}
|
||||||
|
toolkit := freetokenToolkit()
|
||||||
|
if toolkit == "" {
|
||||||
|
return nil, fmt.Errorf("freetoken compiles its kernels on first start and this node has no CUDA 13 toolkit " +
|
||||||
|
"(nvcc in CUDA_HOME, /usr/local/cuda or /usr/local/cuda-13.*)")
|
||||||
|
}
|
||||||
|
p := sh.Profile
|
||||||
|
args := []string{"serve",
|
||||||
|
"--model", p.ModelPath,
|
||||||
|
"--host", sh.Host(),
|
||||||
|
"--port", strconv.Itoa(sh.HTTPPort),
|
||||||
|
"--gpu", "0",
|
||||||
|
}
|
||||||
|
if p.ServedModelName != "" {
|
||||||
|
args = append(args, "--served-model-name", p.ServedModelName)
|
||||||
|
}
|
||||||
|
args = append(args, p.Args...)
|
||||||
|
env := baseEnv(sh)
|
||||||
|
env["CUDA_HOME"] = toolkit
|
||||||
|
// nvcc for the kernels it compiles, and the environment's own bin
|
||||||
|
// first, where ninja is.
|
||||||
|
env["PATH"] = strings.Join([]string{filepath.Dir(bin), filepath.Join(toolkit, "bin"), os.Getenv("PATH")}, ":")
|
||||||
|
if sh.APIKey != "" {
|
||||||
|
env["FREETOKEN_API_KEY"] = sh.APIKey
|
||||||
|
}
|
||||||
|
return []Command{{Name: "freetoken", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
package engines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func withToolkit(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
old := freetokenToolkit
|
||||||
|
freetokenToolkit = func() string { return dir }
|
||||||
|
t.Cleanup(func() { freetokenToolkit = old })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreeTokenServesOnTheShardsCardWithTheKeyInTheEnvironment(t *testing.T) {
|
||||||
|
withToolkit(t, "/usr/local/cuda-13.4")
|
||||||
|
f := &FreeToken{}
|
||||||
|
f.UseInstalled("/opt/llmgw/engines/freetoken/0.2.1-cu13/bin/ft")
|
||||||
|
spec := ShardSpec{
|
||||||
|
ReplicaID: "r1", HTTPPort: 18091, BindAddr: "10.0.0.160", GPUIndices: []int{3},
|
||||||
|
APIKey: "secret-key",
|
||||||
|
Profile: Profile{Name: "p", Engine: "freetoken", TP: 1, ModelPath: "/var/lib/llmgw-weights/glm-5.2-nvfp4",
|
||||||
|
ServedModelName: "glm-5.2", Args: []string{"--max-running-requests", "8"}},
|
||||||
|
}
|
||||||
|
cmds, err := f.Commands(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := cmds[0]
|
||||||
|
if c.Path != "/opt/llmgw/engines/freetoken/0.2.1-cu13/bin/ft" {
|
||||||
|
t.Errorf("path %s", c.Path)
|
||||||
|
}
|
||||||
|
args := strings.Join(c.Args, " ")
|
||||||
|
for _, want := range []string{
|
||||||
|
"serve --model /var/lib/llmgw-weights/glm-5.2-nvfp4", "--host 10.0.0.160", "--port 18091",
|
||||||
|
// The shard's card is device 0 once CUDA_VISIBLE_DEVICES pins it.
|
||||||
|
"--gpu 0", "--served-model-name glm-5.2", "--max-running-requests 8",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(args, want) {
|
||||||
|
t.Errorf("args lack %q: %s", want, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(args, "secret-key") {
|
||||||
|
t.Error("the key is on the command line, which every user on the node can read")
|
||||||
|
}
|
||||||
|
for _, want := range []string{"FREETOKEN_API_KEY=secret-key", "CUDA_VISIBLE_DEVICES=3", "CUDA_HOME=/usr/local/cuda-13.4"} {
|
||||||
|
if !slices.Contains(c.Env, want) {
|
||||||
|
t.Errorf("env lacks %s", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var path string
|
||||||
|
for _, e := range c.Env {
|
||||||
|
if p, ok := strings.CutPrefix(e, "PATH="); ok {
|
||||||
|
path = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(path, "/opt/llmgw/engines/freetoken/0.2.1-cu13/bin:/usr/local/cuda-13.4/bin:") {
|
||||||
|
t.Errorf("nvcc and the environment's tools are not first on PATH: %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreeTokenWithoutTheToolkitSaysSoInsteadOfFailingOnItsFirstModel(t *testing.T) {
|
||||||
|
withToolkit(t, "")
|
||||||
|
f := &FreeToken{cfg: FreeTokenConfig{Binary: "/x/bin/ft"}}
|
||||||
|
_, err := f.Commands(ShardSpec{HTTPPort: 1, Profile: Profile{Name: "p", ModelPath: "/m"}})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "CUDA 13 toolkit") {
|
||||||
|
t.Errorf("got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreeTokenRunsOnOneAmpereOrNewerCard(t *testing.T) {
|
||||||
|
f := &FreeToken{}
|
||||||
|
p := Profile{Name: "p", Engine: "freetoken", TP: 1, ModelPath: "/m"}
|
||||||
|
if err := f.Validate(p, []GPUInfo{{Name: "NVIDIA RTX A6000", ComputeCapability: "8.6", MemoryTotalMB: 49140}}); err != nil {
|
||||||
|
t.Errorf("an Ampere card was refused: %v", err)
|
||||||
|
}
|
||||||
|
// Tried on cuda01: no kernel image for the card, then an illegal memory
|
||||||
|
// access in fp16.
|
||||||
|
if err := f.Validate(p, []GPUInfo{{Name: "Quadro RTX 6000", ComputeCapability: "7.5", MemoryTotalMB: 24576}}); err == nil {
|
||||||
|
t.Error("a Turing card was accepted")
|
||||||
|
}
|
||||||
|
p.TP = 2
|
||||||
|
if err := f.Validate(p, h100(2)); err == nil {
|
||||||
|
t.Error("a two-card profile was accepted; FreeToken has no tensor parallel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFreeTokenFlagsThatReadFilesOrMoveWhatTheAgentSetsAreRefused(t *testing.T) {
|
||||||
|
for _, a := range []string{
|
||||||
|
"--gpu", "--served-model-name=x", "--mmproj", "--allowed-local-media-path",
|
||||||
|
// argparse takes a beginning for the whole flag.
|
||||||
|
"--allowed-local", "--api-key", "--port", "--hf-overrides",
|
||||||
|
} {
|
||||||
|
if UnsafeArgs("freetoken", []string{a, "v"}) == nil {
|
||||||
|
t.Errorf("%s was allowed", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range []string{"--max-running-requests", "--moe-strategy", "--dtype", "--max-seq-len-override", "--moe-cache-auto"} {
|
||||||
|
if err := UnsafeArgs("freetoken", []string{a, "1"}); err != nil {
|
||||||
|
t.Errorf("%s was refused: %v", a, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for k, refuse := range map[string]bool{
|
||||||
|
"FREETOKEN_PIN_BUDGET_GB": false, "FREETOKEN_API_KEY": true, "FREETOKEN_API_LOG_DIR": true,
|
||||||
|
"FREETOKEN_MMPROJ": true, "FREETOKEN_HOST": true,
|
||||||
|
} {
|
||||||
|
if got, why := UnsafeEnv(k); got != refuse {
|
||||||
|
t.Errorf("%s: refused %v (%s), want %v", k, got, why, refuse)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package engines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"slices"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAModelDownloadedByTheEngineIsReadAsSafetensorsOnly(t *testing.T) {
|
||||||
|
for path, want := range map[string]bool{
|
||||||
|
"Qwen/Qwen2.5-7B-Instruct": true,
|
||||||
|
"/var/lib/llmgw/weights/abc/": false, "./local": false,
|
||||||
|
"http://m/q/v/": false, "q.gguf": false,
|
||||||
|
} {
|
||||||
|
got := slices.Contains(safetensorsOnly(Profile{ModelPath: path}), "safetensors")
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("%q: safetensors only = %v, want %v", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(safetensorsOnly(Profile{ModelPath: "Qwen/Qwen2.5-7B-Instruct", AllowPickle: true})) != 0 {
|
||||||
|
t.Error("allow_pickle still forced safetensors")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAForbiddenFlagIsKnownHoweverItIsSpelled(t *testing.T) {
|
||||||
|
for engine, args := range map[string][]string{
|
||||||
|
"vllm": {"--trust_remote_code"},
|
||||||
|
"sglang": {"--load-format", "pt"},
|
||||||
|
} {
|
||||||
|
if UnsafeArgs(engine, args) == nil {
|
||||||
|
t.Errorf("%s %v was let through", engine, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, a := range [][]string{{"--load_format", "pt"}, {"--trust-remote"}, {"--load-form=pt"}} {
|
||||||
|
if UnsafeArgs("vllm", a) == nil {
|
||||||
|
t.Errorf("vllm %v was let through", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// What is not one of them, nor the start of one, still passes.
|
||||||
|
for _, a := range [][]string{{"--max-model-len", "8192"}, {"--tokenizer-mode", "mistral"}, {"--gpu-memory-utilization=0.9"}} {
|
||||||
|
if err := UnsafeArgs("vllm", a); err != nil {
|
||||||
|
t.Errorf("vllm %v was refused: %v", a, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Short flags are matched as written; llama.cpp takes no abbreviations,
|
||||||
|
// so its --chat-template is not taken for --chat-template-file.
|
||||||
|
for _, a := range [][]string{{"-m", "x"}, {"-hf", "o/r"}, {"-mu", "http://x"}} {
|
||||||
|
if UnsafeArgs("llamacpp", a) == nil {
|
||||||
|
t.Errorf("llamacpp %v was let through", a)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := UnsafeArgs("llamacpp", []string{"--chat-template", "chatml"}); err != nil {
|
||||||
|
t.Errorf("llama.cpp's own --chat-template was refused: %v", err)
|
||||||
|
}
|
||||||
|
// ComfyUI's argparse reads --li as --listen.
|
||||||
|
if UnsafeArgs("comfyui", []string{"--li", "0.0.0.0"}) == nil {
|
||||||
|
t.Error("comfyui --li was let through")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -102,6 +102,7 @@ func (g *SGLang) serverCommand(s ShardSpec) (Command, error) {
|
|||||||
if p.ServedModelName != "" {
|
if p.ServedModelName != "" {
|
||||||
args = append(args, "--served-model-name", p.ServedModelName)
|
args = append(args, "--served-model-name", p.ServedModelName)
|
||||||
}
|
}
|
||||||
|
args = append(args, safetensorsOnly(p)...)
|
||||||
if s.NNodes > 1 {
|
if s.NNodes > 1 {
|
||||||
args = append(args,
|
args = append(args,
|
||||||
"--nnodes", strconv.Itoa(s.NNodes),
|
"--nnodes", strconv.Itoa(s.NNodes),
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
package engines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StrataConfig locates a Strata installation.
|
||||||
|
type StrataConfig struct {
|
||||||
|
// Dir is Strata's source directory, where setup.py and serve/ are.
|
||||||
|
Dir string `koanf:"dir"`
|
||||||
|
// Python is the interpreter of its environment; Dir's .venv when unset.
|
||||||
|
Python string `koanf:"python"`
|
||||||
|
// Engine is the directory of its prebuilt engine, where the strata
|
||||||
|
// binary and the CUDA libraries it ships are; Dir's engine/ when unset.
|
||||||
|
Engine string `koanf:"engine"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strata runs Strata (github.com/Niko1221/Strata): a mixture-of-experts
|
||||||
|
// engine that keeps a model's experts in RAM and the ones used most in VRAM,
|
||||||
|
// so a 125B model serves from one 24 GB card. It runs a few model families
|
||||||
|
// only, each prepared from its GGUF files by Strata's own setup, so the agent
|
||||||
|
// starts llmgw strata-serve, which prepares the model and then becomes
|
||||||
|
// Strata's server.
|
||||||
|
type Strata struct {
|
||||||
|
cfg StrataConfig
|
||||||
|
installed atomic.Pointer[string]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name identifies the engine in the catalog.
|
||||||
|
func (*Strata) Name() string { return "strata" }
|
||||||
|
|
||||||
|
// HealthPath answers 200 only once the model is loaded.
|
||||||
|
func (*Strata) HealthPath() string { return "/health" }
|
||||||
|
|
||||||
|
// MetricsPath is Strata's Prometheus text, in vLLM's names; its /metrics is
|
||||||
|
// JSON for its own monitor unless asked for this format.
|
||||||
|
func (*Strata) MetricsPath() string { return "/metrics?format=prometheus" }
|
||||||
|
|
||||||
|
// ServesHTTP is true: a Strata replica is one process on one node.
|
||||||
|
func (*Strata) ServesHTTP(ShardSpec) bool { return true }
|
||||||
|
|
||||||
|
// UseInstalled points the engine at the environment the agent installed:
|
||||||
|
// its interpreter, with Strata's code beside its bin in strata/ and the
|
||||||
|
// engine in strata-engine/.
|
||||||
|
func (s *Strata) UseInstalled(python string) { s.installed.Store(&python) }
|
||||||
|
|
||||||
|
// Configured reports whether the configuration names a Strata.
|
||||||
|
func (s *Strata) Configured() bool { return s.cfg.Dir != "" }
|
||||||
|
|
||||||
|
// StrataSourceDir and StrataEngineDir are where an installed environment
|
||||||
|
// keeps Strata's code and its prebuilt engine, beside bin/.
|
||||||
|
const (
|
||||||
|
StrataSourceDir = "strata"
|
||||||
|
StrataEngineDir = "strata-engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Strata) env() string {
|
||||||
|
if p := s.installed.Load(); p != nil {
|
||||||
|
return filepath.Dir(filepath.Dir(*p))
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Strata) dir() string {
|
||||||
|
if e := s.env(); e != "" {
|
||||||
|
return filepath.Join(e, StrataSourceDir)
|
||||||
|
}
|
||||||
|
return s.cfg.Dir
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Strata) engineDir() string {
|
||||||
|
if e := s.env(); e != "" {
|
||||||
|
return filepath.Join(e, StrataEngineDir)
|
||||||
|
}
|
||||||
|
if s.cfg.Engine != "" {
|
||||||
|
return s.cfg.Engine
|
||||||
|
}
|
||||||
|
return filepath.Join(s.cfg.Dir, "engine")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Strata) python() string {
|
||||||
|
if p := s.installed.Load(); p != nil {
|
||||||
|
return *p
|
||||||
|
}
|
||||||
|
if s.cfg.Python != "" {
|
||||||
|
return s.cfg.Python
|
||||||
|
}
|
||||||
|
return filepath.Join(s.cfg.Dir, ".venv", "bin", "python")
|
||||||
|
}
|
||||||
|
|
||||||
|
var strataVersion = regexp.MustCompile(`PRODUCT_VERSION\s*=\s*"([^"]+)"`)
|
||||||
|
|
||||||
|
// Probe reports the Strata a configuration names, by the version its source
|
||||||
|
// states: a directory with its setup and server, an interpreter, and the
|
||||||
|
// engine binary. Without it a Strata named by hand was never reported, and
|
||||||
|
// so never placed on.
|
||||||
|
func (s *Strata) Probe(context.Context) (string, error) {
|
||||||
|
dir := s.dir()
|
||||||
|
if dir == "" {
|
||||||
|
return "", fmt.Errorf("no Strata directory is configured")
|
||||||
|
}
|
||||||
|
for _, f := range []string{filepath.Join(dir, "setup.py"), filepath.Join(dir, "serve", "server.py"),
|
||||||
|
s.python(), filepath.Join(s.engineDir(), "strata")} {
|
||||||
|
if _, err := os.Stat(f); err != nil {
|
||||||
|
return "", fmt.Errorf("%s is not a whole Strata: %w", dir, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b, err := os.ReadFile(filepath.Join(dir, "strata_cli", "__init__.py"))
|
||||||
|
if err != nil {
|
||||||
|
return Unknown, nil //nolint:nilerr // whole, of a version it does not say
|
||||||
|
}
|
||||||
|
if m := strataVersion.FindSubmatch(b); m != nil {
|
||||||
|
return string(m[1]), nil
|
||||||
|
}
|
||||||
|
return Unknown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks the profile: one card, on one node. Strata splits a model
|
||||||
|
// over a node's cards by layer, but that is not driven from here yet.
|
||||||
|
func (*Strata) Validate(p Profile, gpus []GPUInfo) error {
|
||||||
|
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
|
||||||
|
return fmt.Errorf("profile %q: strata runs on one GPU of one node", p.Name)
|
||||||
|
}
|
||||||
|
// Pascal and Volta run its CUDA 12 build, which the install picks for
|
||||||
|
// them.
|
||||||
|
if err := requireComputeCapability(p, gpus, 6.0, "strata"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// A family whose engine needs more: said now, rather than by Strata's
|
||||||
|
// setup after the replica was placed here.
|
||||||
|
if fam, ok := strata.FamilyNamed(strata.ArgValue(p.Args, "--family")); ok && fam.MinCC > 0 {
|
||||||
|
if err := requireComputeCapability(p, gpus, fam.MinCC, "strata's "+fam.Title+" engine"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return validateCommon(p, gpus)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commands is llmgw strata-serve, which prepares the model with Strata's
|
||||||
|
// setup and then execs Strata's server.
|
||||||
|
func (s *Strata) Commands(sh ShardSpec) ([]Command, error) {
|
||||||
|
dir := s.dir()
|
||||||
|
if dir == "" {
|
||||||
|
return nil, fmt.Errorf("strata: no Strata directory is configured (engines.strata.dir)")
|
||||||
|
}
|
||||||
|
bin, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
p := sh.Profile
|
||||||
|
args := []string{"strata-serve",
|
||||||
|
"--listen", net.JoinHostPort(sh.Host(), strconv.Itoa(sh.HTTPPort)),
|
||||||
|
"--model", p.ServedModelName,
|
||||||
|
"--weights", p.ModelPath,
|
||||||
|
// Relative to where engines run — their user's home — so they are
|
||||||
|
// theirs to write. The data is shared by the node's replicas: a
|
||||||
|
// prepared model and its draft layer are gigabytes, the same for
|
||||||
|
// every replica of it.
|
||||||
|
"--state", filepath.Join("strata", sh.ReplicaID),
|
||||||
|
"--data", filepath.Join("strata", "data"),
|
||||||
|
"--python", s.python(),
|
||||||
|
"--strata", dir,
|
||||||
|
"--engine", s.engineDir(),
|
||||||
|
"--",
|
||||||
|
}
|
||||||
|
args = append(args, p.Args...)
|
||||||
|
env := baseEnv(sh)
|
||||||
|
if sh.APIKey != "" {
|
||||||
|
// Strata's server reads its key from here, and strata-serve passes
|
||||||
|
// it on in the environment: not on a command line every user on the
|
||||||
|
// node can read.
|
||||||
|
env["STRATA_API_KEY"] = sh.APIKey
|
||||||
|
}
|
||||||
|
return []Command{{Name: "strata", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package engines
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStrataStartsStrataServeWithTheKeyInTheEnvironment(t *testing.T) {
|
||||||
|
s := &Strata{}
|
||||||
|
s.UseInstalled("/opt/llmgw/engines/strata/0.2.7-cuda13/bin/python")
|
||||||
|
spec := ShardSpec{
|
||||||
|
ReplicaID: "r1", HTTPPort: 18090, BindAddr: "10.0.0.147", GPUIndices: []int{0},
|
||||||
|
APIKey: "secret-key",
|
||||||
|
Profile: Profile{Name: "p", Engine: "strata", TP: 1, ModelPath: "/var/lib/llmgw/models/q38",
|
||||||
|
ServedModelName: "qwen3.8-flash-next", Args: []string{"--family", "qwen", "--size", "IQ2_XS"}},
|
||||||
|
}
|
||||||
|
cmds, err := s.Commands(spec)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
c := cmds[0]
|
||||||
|
args := strings.Join(c.Args, " ")
|
||||||
|
env := "/opt/llmgw/engines/strata/0.2.7-cuda13"
|
||||||
|
for _, want := range []string{
|
||||||
|
"strata-serve", "--listen 10.0.0.147:18090", "--model qwen3.8-flash-next",
|
||||||
|
"--weights /var/lib/llmgw/models/q38", "--state " + filepath.Join("strata", "r1"),
|
||||||
|
"--python " + env + "/bin/python", "--strata " + env + "/strata", "--engine " + env + "/strata-engine",
|
||||||
|
"-- --family qwen --size IQ2_XS",
|
||||||
|
} {
|
||||||
|
if !strings.Contains(args, want) {
|
||||||
|
t.Errorf("args lack %q: %s", want, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(args, "secret-key") {
|
||||||
|
t.Error("the key is on the command line, which every user on the node can read")
|
||||||
|
}
|
||||||
|
if !slices.Contains(c.Env, "STRATA_API_KEY=secret-key") {
|
||||||
|
t.Error("the key is not in the environment, where Strata's server reads it")
|
||||||
|
}
|
||||||
|
if !slices.Contains(c.Env, "CUDA_VISIBLE_DEVICES=0") {
|
||||||
|
t.Error("the shard is not pinned to its GPU")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrataRunsOnOneCard(t *testing.T) {
|
||||||
|
s := &Strata{}
|
||||||
|
p := Profile{Name: "p", Engine: "strata", TP: 1, GPUsPerNode: 2, ModelPath: "/m"}
|
||||||
|
if err := s.Validate(p, nil); err == nil {
|
||||||
|
t.Error("a two-card profile was accepted")
|
||||||
|
}
|
||||||
|
p.GPUsPerNode = 1
|
||||||
|
if err := s.Validate(p, []GPUInfo{{Name: "Quadro RTX 6000", ComputeCapability: "7.5"}}); err != nil {
|
||||||
|
t.Errorf("one Turing card was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrataSetupFlagsThatMoveFilesAreRefused(t *testing.T) {
|
||||||
|
for _, bad := range []string{"--gguf-dir", "--gguf", "--data-dir=/etc", "--model", "--mod", "--feed", "--host", "--gpus"} {
|
||||||
|
if err := UnsafeArgs("strata", []string{bad, "x"}); err == nil {
|
||||||
|
t.Errorf("%s was accepted", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := UnsafeArgs("strata", []string{"--family", "qwen", "--size", "IQ2_XS", "--context", "32768",
|
||||||
|
"--parallel", "2", "--vram-reserve-mib", "2048", "--vision", "no"}); err != nil {
|
||||||
|
t.Errorf("an ordinary profile was refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAStrataNamedByHandIsReportedWithItsVersion(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
for _, f := range []string{"setup.py", "serve/server.py", ".venv/bin/python", "engine/strata"} {
|
||||||
|
p := filepath.Join(dir, f)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(p, nil, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s := &Strata{cfg: StrataConfig{Dir: dir}}
|
||||||
|
if v, err := s.Probe(t.Context()); err != nil || v != Unknown {
|
||||||
|
t.Fatalf("%q %v", v, err)
|
||||||
|
}
|
||||||
|
_ = os.MkdirAll(filepath.Join(dir, "strata_cli"), 0o755)
|
||||||
|
_ = os.WriteFile(filepath.Join(dir, "strata_cli", "__init__.py"), []byte("PRODUCT_VERSION = \"0.2.7\"\n"), 0o644)
|
||||||
|
if v, _ := s.Probe(t.Context()); v != "0.2.7" {
|
||||||
|
t.Errorf("version %q", v)
|
||||||
|
}
|
||||||
|
r := NewRegistry(Config{Strata: StrataConfig{Dir: dir}})
|
||||||
|
if got := r.ProbeConfigured(t.Context()); got["strata"] != "0.2.7" {
|
||||||
|
t.Errorf("not reported: %v", got)
|
||||||
|
}
|
||||||
|
_ = os.Remove(filepath.Join(dir, "engine", "strata"))
|
||||||
|
if _, err := s.Probe(t.Context()); err == nil {
|
||||||
|
t.Error("reported without its engine")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrataRefusesDeepSeekOnACardItsEngineWasNotBuiltFor(t *testing.T) {
|
||||||
|
s := &Strata{}
|
||||||
|
p := Profile{Name: "p", Engine: "strata", ModelPath: "/m", TP: 1, GPUsPerNode: 1, Args: []string{"--family", "deepseek41", "--size", "IST-DAS-1.75bpw"}}
|
||||||
|
gpus := []GPUInfo{{Index: 0, Name: "Quadro RTX 6000", ComputeCapability: "7.5"}}
|
||||||
|
if err := s.Validate(p, gpus); err == nil || !strings.Contains(err.Error(), "8.6") {
|
||||||
|
t.Errorf("DeepSeek on 7.5: %v", err)
|
||||||
|
}
|
||||||
|
p.Args = []string{"--family", "qwen", "--size", "IQ2_XS"}
|
||||||
|
if err := s.Validate(p, gpus); err != nil {
|
||||||
|
t.Errorf("Qwen on 7.5: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package engines
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ var envFamilies = []string{
|
|||||||
"CUDA_", "NCCL_", "NVIDIA_", "NVTE_", "GLOO_", "UCX_", "TORCH_", "PYTORCH_",
|
"CUDA_", "NCCL_", "NVIDIA_", "NVTE_", "GLOO_", "UCX_", "TORCH_", "PYTORCH_",
|
||||||
"VLLM_", "SGLANG_", "GGML_", "LLAMA_", "OMP_", "MKL_", "OPENBLAS_", "KMP_",
|
"VLLM_", "SGLANG_", "GGML_", "LLAMA_", "OMP_", "MKL_", "OPENBLAS_", "KMP_",
|
||||||
"HF_", "TRANSFORMERS_", "TOKENIZERS_", "TRITON_", "FLASHINFER_",
|
"HF_", "TRANSFORMERS_", "TOKENIZERS_", "TRITON_", "FLASHINFER_",
|
||||||
"TRTLLM_", "TLLM_",
|
"TRTLLM_", "TLLM_", "FREETOKEN_",
|
||||||
}
|
}
|
||||||
|
|
||||||
// envExact are single variables that are tuning too.
|
// envExact are single variables that are tuning too.
|
||||||
@@ -32,6 +33,14 @@ var envExact = map[string]bool{
|
|||||||
"PYTHONUNBUFFERED": true, "PYTHONHASHSEED": true, "TZ": true,
|
"PYTHONUNBUFFERED": true, "PYTHONHASHSEED": true, "TZ": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// envNeverExact are variables in an allowed family that no profile may set,
|
||||||
|
// whose names the patterns below do not catch: what the agent sets itself,
|
||||||
|
// and FreeToken's way of loading a projector file.
|
||||||
|
var envNeverExact = map[string]string{
|
||||||
|
"FREETOKEN_API_KEY": "is set by the agent", "FREETOKEN_HOST": "is set by the agent",
|
||||||
|
"FREETOKEN_MMPROJ": "loads a file",
|
||||||
|
}
|
||||||
|
|
||||||
// envNever are names in an allowed family that load code or move where the
|
// envNever are names in an allowed family that load code or move where the
|
||||||
// engine reads and writes.
|
// engine reads and writes.
|
||||||
var envNever = []string{
|
var envNever = []string{
|
||||||
@@ -58,6 +67,9 @@ func UnsafeEnv(key string) (bool, string) {
|
|||||||
return true, "only the variables engines are tuned with are allowed (" +
|
return true, "only the variables engines are tuned with are allowed (" +
|
||||||
strings.Join(envFamilies, "*, ") + "*)"
|
strings.Join(envFamilies, "*, ") + "*)"
|
||||||
}
|
}
|
||||||
|
if why, ok := envNeverExact[k]; ok {
|
||||||
|
return true, "it " + why
|
||||||
|
}
|
||||||
for _, n := range envNever {
|
for _, n := range envNever {
|
||||||
if strings.Contains(k, n) && !envExact[k] {
|
if strings.Contains(k, n) && !envExact[k] {
|
||||||
return true, "it names a path, a plugin, a library or an address, which is a way to make the engine " +
|
return true, "it names a path, a plugin, a library or an address, which is a way to make the engine " +
|
||||||
@@ -101,6 +113,13 @@ var flagsNever = map[string]map[string]string{
|
|||||||
"--hf-token": "is a credential", "--alias": "is set by the agent",
|
"--hf-token": "is a credential", "--alias": "is set by the agent",
|
||||||
"--model-draft": "loads a file", "-md": "loads a file",
|
"--model-draft": "loads a file", "-md": "loads a file",
|
||||||
"--prompt-cache": "writes a file", "--logdir": "writes files",
|
"--prompt-cache": "writes a file", "--logdir": "writes files",
|
||||||
|
// The short spellings of the same.
|
||||||
|
"-mm": "loads a file; the projector is fetched with the model", "--mmproj-url": "downloads a file",
|
||||||
|
"-mmu": "downloads a file", "-hfr": "downloads a model", "-hff": "downloads a model",
|
||||||
|
"--hf-repo-draft": "downloads a model", "-hfd": "downloads a model", "-hfrd": "downloads a model",
|
||||||
|
"--hf-repo-v": "downloads a model", "-hfv": "downloads a model", "--hf-file-v": "downloads a model",
|
||||||
|
"-hffv": "downloads a model", "-hft": "is a credential", "-jf": "reads a file", "-a": "is set by the agent",
|
||||||
|
"--model-vocoder": "loads a file", "-mv": "loads a file",
|
||||||
},
|
},
|
||||||
"vllm": {
|
"vllm": {
|
||||||
"--download-dir": "writes files", "--tokenizer": "reads another repository or path",
|
"--download-dir": "writes files", "--tokenizer": "reads another repository or path",
|
||||||
@@ -118,6 +137,7 @@ var flagsNever = map[string]map[string]string{
|
|||||||
"--file-storage-path": "writes files", "--log-requests": "logs prompts",
|
"--file-storage-path": "writes files", "--log-requests": "logs prompts",
|
||||||
"--model-path": "is the model's source, set by the agent", "--served-model-name": "is set by the agent",
|
"--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",
|
||||||
|
"--load-format": "changes how weights are loaded, some ways running code",
|
||||||
},
|
},
|
||||||
// ComfyUI's main.py, behind the gate that sets where it listens and
|
// ComfyUI's main.py, behind the gate that sets where it listens and
|
||||||
// where its files are.
|
// where its files are.
|
||||||
@@ -132,6 +152,32 @@ var flagsNever = map[string]map[string]string{
|
|||||||
"--tls-keyfile": "reads a file", "--tls-certfile": "reads a file",
|
"--tls-keyfile": "reads a file", "--tls-certfile": "reads a file",
|
||||||
"--comfy-api-base": "sends work to another service",
|
"--comfy-api-base": "sends work to another service",
|
||||||
},
|
},
|
||||||
|
// Strata's setup.py, which a profile's arguments go to: llmgw
|
||||||
|
// strata-serve sets where the model is, where its files go and where
|
||||||
|
// the server listens, and the engine is the one the agent installed.
|
||||||
|
// Its own --model, the size, is spelt --size in a profile, since
|
||||||
|
// --model is the source everywhere else.
|
||||||
|
"strata": {
|
||||||
|
"--gguf-dir": "is the model's source, set by the agent", "--models-dir": "is set by the agent",
|
||||||
|
"--data-dir": "is set by the agent", "--move-data": "moves files", "--set-default-data-dir": "writes settings",
|
||||||
|
"--feed": "downloads an engine", "--prebuilt": "loads an engine", "--build": "compiles an engine",
|
||||||
|
"--update": "downloads an engine", "--rollback-engine": "changes the engine",
|
||||||
|
"--dsv41-package": "loads a file", "--inspect": "reads another repository or path",
|
||||||
|
"--source": "downloads from elsewhere", "--setup": "is not for a server",
|
||||||
|
"--browser": "opens a browser", "--gpu": "is set by the agent", "--gpus": "is set by the agent",
|
||||||
|
"--gpu-roles": "is set by the agent", "--backend": "is set by the agent",
|
||||||
|
"--experimental-speed-projection": "changes what the model refuses",
|
||||||
|
},
|
||||||
|
// ft serve: where it listens, its key, its model and its card are the
|
||||||
|
// agent's, and what reads files or fetches is refused.
|
||||||
|
"freetoken": {
|
||||||
|
"--gpu": "is set by the agent", "--served-model-name": "is set by the agent",
|
||||||
|
"--mmproj": "loads a file", "--mm-encoder-weights": "loads a file",
|
||||||
|
"--allowed-local-media-path": "serves local files to requests",
|
||||||
|
"--allowed-media-domains": "lets requests have the engine fetch from those sites",
|
||||||
|
"--moe-cache-split": "reads a profile file",
|
||||||
|
"--hf-overrides": "rewrites the checkpoint's configuration, which can name code to load",
|
||||||
|
},
|
||||||
// trtllm-serve spells its flags with underscores, so the shared list's
|
// 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": {
|
||||||
@@ -163,14 +209,43 @@ var flagsNever = map[string]map[string]string{
|
|||||||
// unsafeFlag is why an engine may not be given a flag, or "".
|
// unsafeFlag is why an engine may not be given a flag, or "".
|
||||||
func unsafeFlag(engine, flag, _ string) string {
|
func unsafeFlag(engine, flag, _ string) string {
|
||||||
name, _, _ := strings.Cut(flag, "=")
|
name, _, _ := strings.Cut(flag, "=")
|
||||||
|
if !strings.HasPrefix(name, "-") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
// Written as the engine would read it: vLLM takes --load_format for
|
||||||
|
// --load-format, TensorRT-LLM the other way round. Short flags (-m,
|
||||||
|
// -hf) are matched as written.
|
||||||
|
norm := func(f string) string {
|
||||||
|
if strings.HasPrefix(f, "--") {
|
||||||
|
return "--" + strings.ReplaceAll(f[2:], "_", "-")
|
||||||
|
}
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
given := norm(name)
|
||||||
|
// argparse also takes any unambiguous beginning of a long flag for the
|
||||||
|
// flag — --trust-remote is --trust-remote-code, --li is ComfyUI's
|
||||||
|
// --listen — so for the engines that parse with it, a beginning of a
|
||||||
|
// refused flag is refused. llama.cpp and TensorRT-LLM take no such
|
||||||
|
// thing, and --chat-template there is a flag of its own.
|
||||||
|
abbreviates := argparseEngines[engine] && strings.HasPrefix(given, "--") && len(given) >= 4
|
||||||
for _, set := range []string{"", engine} {
|
for _, set := range []string{"", engine} {
|
||||||
if why, ok := flagsNever[set][name]; ok && why != "" {
|
for never, why := range flagsNever[set] {
|
||||||
return why
|
if why == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n := norm(never)
|
||||||
|
if n == given || (abbreviates && strings.HasPrefix(n, "--") && strings.HasPrefix(n, given)) {
|
||||||
|
return why
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// argparseEngines read their flags with Python's argparse, which takes a
|
||||||
|
// long flag's beginning for the flag.
|
||||||
|
var argparseEngines = map[string]bool{"vllm": true, "sglang": true, "comfyui": true, "strata": true, "freetoken": true}
|
||||||
|
|
||||||
// UnsafeArgs refuses the first of a profile's arguments an engine may not be
|
// UnsafeArgs refuses the first of a profile's arguments an engine may not be
|
||||||
// given, saying why.
|
// given, saying why.
|
||||||
func UnsafeArgs(engine string, args []string) error {
|
func UnsafeArgs(engine string, args []string) error {
|
||||||
@@ -185,3 +260,29 @@ func UnsafeArgs(engine string, args []string) error {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// safetensorsOnly has vLLM or SGLang read only safetensors from a model it
|
||||||
|
// downloads itself, by its Hugging Face id: left to choose, either takes
|
||||||
|
// the repository's pickle files (pytorch_model.bin) when it has no
|
||||||
|
// safetensors, and loading those runs whatever they hold. A path was
|
||||||
|
// fetched by the agent, which checked it, or put there by the operator.
|
||||||
|
func safetensorsOnly(p Profile) []string {
|
||||||
|
if p.AllowPickle || !hubID(p.ModelPath) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []string{"--load-format", "safetensors"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hubID reports whether a model path is a Hugging Face id, org/name, rather
|
||||||
|
// than a file or directory.
|
||||||
|
func hubID(s string) bool {
|
||||||
|
if s == "" || strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") || strings.Contains(s, "://") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// org/name:Q4_K_M is a GGUF in a repository, which vLLM reads as one.
|
||||||
|
if strings.Count(s, "/") != 1 || strings.Contains(s, ":") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, err := os.Stat(s)
|
||||||
|
return err != nil
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@ func (v *VLLM) serverCommand(s ShardSpec) (Command, error) {
|
|||||||
if p.ServedModelName != "" {
|
if p.ServedModelName != "" {
|
||||||
args = append(args, "--served-model-name", p.ServedModelName)
|
args = append(args, "--served-model-name", p.ServedModelName)
|
||||||
}
|
}
|
||||||
|
args = append(args, safetensorsOnly(p)...)
|
||||||
|
|
||||||
env := baseEnv(s)
|
env := baseEnv(s)
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ var (
|
|||||||
gpuProcesses = discovery.GPUProcesses
|
gpuProcesses = discovery.GPUProcesses
|
||||||
gpuMemory = discovery.GPUMemoryByProcess
|
gpuMemory = discovery.GPUMemoryByProcess
|
||||||
processGroup = pgidOf
|
processGroup = pgidOf
|
||||||
|
parentOf = ppidOf
|
||||||
// gpuCheckWait is how long a shard has, once it answers, to show up on
|
// gpuCheckWait is how long a shard has, once it answers, to show up on
|
||||||
// its GPUs: the weights are on them by then, but nvidia-smi can lag.
|
// its GPUs: the weights are on them by then, but nvidia-smi can lag.
|
||||||
gpuCheckWait = 15 * time.Second
|
gpuCheckWait = 15 * time.Second
|
||||||
@@ -116,19 +117,44 @@ func (a *Agent) foreignGPUMemory(ctx context.Context, shards []int) (foreign, us
|
|||||||
return foreign, used
|
return foreign, used
|
||||||
}
|
}
|
||||||
|
|
||||||
// holds reports whether any of pids is the shard's process or in its group:
|
// holds reports whether any of pids is the shard's process, in its group,
|
||||||
// an engine that computes in a child process is still the shard.
|
// or a process it started: an engine that computes in a child process is
|
||||||
|
// still the shard, and Strata's server starts its engine in a group of the
|
||||||
|
// engine's own.
|
||||||
func holds(pids []int, pid, group int) bool {
|
func holds(pids []int, pid, group int) bool {
|
||||||
for _, p := range pids {
|
for _, p := range pids {
|
||||||
if p == pid || (group > 0 && processGroup(p) == group) {
|
if p == pid || (group > 0 && processGroup(p) == group) || descends(p, pid) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// descends reports whether process p was started, at any remove, by pid.
|
||||||
|
func descends(p, pid int) bool {
|
||||||
|
// Bounded: a chain this deep is no engine's, and a /proc read racing an
|
||||||
|
// exit must not loop.
|
||||||
|
for range 16 {
|
||||||
|
p = parentOf(p)
|
||||||
|
switch {
|
||||||
|
case p == pid:
|
||||||
|
return true
|
||||||
|
case p <= 1:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// pgidOf reads a process's group from /proc; zero when it cannot.
|
// pgidOf reads a process's group from /proc; zero when it cannot.
|
||||||
func pgidOf(pid int) int {
|
func pgidOf(pid int) int { return statField(pid, 2) }
|
||||||
|
|
||||||
|
// ppidOf reads a process's parent from /proc; zero when it cannot.
|
||||||
|
func ppidOf(pid int) int { return statField(pid, 1) }
|
||||||
|
|
||||||
|
// statField is one of the numbers after the command name in /proc's stat
|
||||||
|
// line: 1 is the parent, 2 the group.
|
||||||
|
func statField(pid, field int) int {
|
||||||
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
@@ -141,10 +167,10 @@ func pgidOf(pid int) int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
f := strings.Fields(s[i+1:])
|
f := strings.Fields(s[i+1:])
|
||||||
if len(f) < 3 {
|
if len(f) <= field {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
n, _ := strconv.Atoi(f[2])
|
n, _ := strconv.Atoi(f[field])
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -123,6 +123,30 @@ func TestForeignGPUMemoryLeavesOutTheAgentsOwnShards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnEngineStartedInAGroupOfItsOwnIsStillTheShards(t *testing.T) {
|
||||||
|
// cuda01: Strata's server (259709) starts its engine (261072) in a
|
||||||
|
// group of the engine's own, and the engine holds the card. It read as
|
||||||
|
// a shard on the CPU, and as 22 GB of somebody else's memory.
|
||||||
|
oldMem, oldGroup, oldParent := gpuMemory, processGroup, parentOf
|
||||||
|
t.Cleanup(func() { gpuMemory, processGroup, parentOf = oldMem, oldGroup, oldParent })
|
||||||
|
processGroup = func(pid int) int { return pid }
|
||||||
|
parentOf = func(pid int) int {
|
||||||
|
return map[int]int{261072: 259709, 259709: 258734, 258734: 1, 999: 1}[pid]
|
||||||
|
}
|
||||||
|
gpuMemory = func(context.Context, string) ([]discovery.GPUMemory, error) {
|
||||||
|
return []discovery.GPUMemory{{Index: 0, UsedMB: 23000,
|
||||||
|
Procs: []discovery.GPUProcess{{PID: 261072, UsedMB: 22226}, {PID: 999, UsedMB: 700}}}}, nil
|
||||||
|
}
|
||||||
|
a := &Agent{log: slog.Default()}
|
||||||
|
if c, ok := a.gpuCompute(context.Background(), []int{0}, 259709); !ok || c.Backend != "CUDA" ||
|
||||||
|
len(c.Devices) != 1 || c.Devices[0] != "GPU 0: 21.7 GB" {
|
||||||
|
t.Errorf("the engine's child on GPU 0: %+v %v", c, ok)
|
||||||
|
}
|
||||||
|
if foreign, _ := a.foreignGPUMemory(context.Background(), []int{259709}); !slices.Equal(foreign, []int32{774}) {
|
||||||
|
t.Errorf("foreign %v; want only what the other process and nobody holds, [774]", foreign)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLlamaCppsOwnWordOnTheCPUIsEnoughToRefuse(t *testing.T) {
|
func TestLlamaCppsOwnWordOnTheCPUIsEnoughToRefuse(t *testing.T) {
|
||||||
eng := fallbackEngine{}
|
eng := fallbackEngine{}
|
||||||
// Without nvidia-smi to ask, the engine's own output says it.
|
// Without nvidia-smi to ask, the engine's own output says it.
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
// Half a gigabyte read per test binary, and contending with the tests
|
||||||
|
// for the CPUs: these never look at the figure.
|
||||||
|
measureBandwidth = func() discovery.Bandwidth { return discovery.Bandwidth{} }
|
||||||
|
bandwidthDelay = func() time.Duration { return 0 }
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package provision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFreeTokenIsInstalledOnlyWhereItsKernelsRun(t *testing.T) {
|
||||||
|
old := freetokenToolkit
|
||||||
|
toolkit := "/usr/local/cuda-13.4"
|
||||||
|
freetokenToolkit = func() string { return toolkit }
|
||||||
|
t.Cleanup(func() { freetokenToolkit = old })
|
||||||
|
|
||||||
|
ampere := discovery.Host{Arch: "amd64", NVIDIADriver: "580.95.05", MinComputeCapability: 8.6}
|
||||||
|
got, err := freetokenCandidates("0.2.1", "", ampere)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := got[0]
|
||||||
|
if b.Binary != "ft" || !slices.Equal(b.Requirements, []string{"freetoken[accel]==0.2.1"}) || !b.GPUCheck || b.Python == "" {
|
||||||
|
t.Errorf("build %+v", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not meant for these, and not reported as a fault: no GPU, a Turing
|
||||||
|
// card (cuda01, where 0.2.1 had no kernel image for it), an Arm node.
|
||||||
|
for name, h := range map[string]discovery.Host{
|
||||||
|
"no gpu": {Arch: "amd64"},
|
||||||
|
"turing": {Arch: "amd64", NVIDIADriver: "615.71.09", MinComputeCapability: 7.5},
|
||||||
|
"aarch64": {Arch: "arm64", NVIDIADriver: "580.95.05", MinComputeCapability: 9.0},
|
||||||
|
} {
|
||||||
|
_, err := freetokenCandidates("0.2.1", "", h)
|
||||||
|
var nh NotHere
|
||||||
|
if !errors.As(err, &nh) {
|
||||||
|
t.Errorf("%s: %v, want NotHere", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Meant for these, and said why not.
|
||||||
|
if _, err := freetokenCandidates("0.2.1", "", discovery.Host{Arch: "amd64", NVIDIADriver: "570.1", MinComputeCapability: 9.0}); err == nil ||
|
||||||
|
!strings.Contains(err.Error(), "driver 580") {
|
||||||
|
t.Errorf("old driver: %v", err)
|
||||||
|
}
|
||||||
|
toolkit = ""
|
||||||
|
_, err = freetokenCandidates("0.2.1", "", ampere)
|
||||||
|
var nh NotHere
|
||||||
|
if err == nil || errors.As(err, &nh) || !strings.Contains(err.Error(), "CUDA 13 toolkit") {
|
||||||
|
t.Errorf("no toolkit: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,7 +174,7 @@ func (in *Installer) one(ctx context.Context, w Want) Status {
|
|||||||
}
|
}
|
||||||
return st
|
return st
|
||||||
}
|
}
|
||||||
if w.Offline != nil && recipe.Repo == "" {
|
if w.Offline != nil && (recipe.Repo == "" || builds[0].Python != "") {
|
||||||
// Said, not attempted: uv would spend minutes failing to reach PyPI,
|
// Said, not attempted: uv would spend minutes failing to reach PyPI,
|
||||||
// and the failure would read like a network fault rather than the
|
// and the failure would read like a network fault rather than the
|
||||||
// design of an offline site.
|
// design of an offline site.
|
||||||
|
|||||||
@@ -109,6 +109,11 @@ func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Buil
|
|||||||
}
|
}
|
||||||
reqs = append(reqs, more...)
|
reqs = append(reqs, more...)
|
||||||
}
|
}
|
||||||
|
if len(b.Assets) > 0 {
|
||||||
|
if err := in.unpackAssets(ctx, r, w, b, tmp, st); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
if b.Wheel != nil {
|
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
|
||||||
@@ -372,6 +377,48 @@ func (in *Installer) unpackSource(ctx context.Context, w Want, b Build, env stri
|
|||||||
return requirementsIn(string(f), b.IndexPackages)
|
return requirementsIn(string(f), b.IndexPackages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unpackAssets fetches a Python engine's release binaries, each checked
|
||||||
|
// against the digest GitHub publishes for it, and unpacks them into the
|
||||||
|
// environment's AssetDir.
|
||||||
|
func (in *Installer) unpackAssets(ctx context.Context, r Recipe, w Want, b Build, env string, st *Status) error {
|
||||||
|
tag := b.AssetTag
|
||||||
|
if tag == "" {
|
||||||
|
tag = w.Version
|
||||||
|
}
|
||||||
|
in.progress(st, "asking GitHub for the checksums of %s %s", r.Repo, tag)
|
||||||
|
sums, err := in.checksums(ctx, r.Repo, tag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
dst := filepath.Join(env, b.AssetDir)
|
||||||
|
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, asset := range b.Assets {
|
||||||
|
want, ok := sums[asset]
|
||||||
|
if !ok {
|
||||||
|
// Refused rather than installed unchecked: this is a binary that
|
||||||
|
// will run on every node.
|
||||||
|
return fmt.Errorf("%s %s publishes no checksum for %s, so it cannot be verified", r.Repo, tag, asset)
|
||||||
|
}
|
||||||
|
url := strings.TrimSuffix(in.downloadBase(), "/") + "/" + r.Repo + "/releases/download/" + tag + "/" + asset
|
||||||
|
archive := filepath.Join(env+".source", asset)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(archive), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := in.download(ctx, url, asset, archive, want, st); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
in.progress(st, "unpacking %s", asset)
|
||||||
|
err := untarGz(archive, dst)
|
||||||
|
_ = os.RemoveAll(filepath.Dir(archive))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("unpacking %s: %w", asset, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// requirementsIn reads a requirements file's lines. Options (-r, -e, --index-url
|
// 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,
|
// and the like) are refused: they would fetch from somewhere other than PyPI,
|
||||||
// or install what the lock never saw.
|
// or install what the lock never saw.
|
||||||
|
|||||||
@@ -76,6 +76,11 @@ type Build struct {
|
|||||||
// was published, which PyPI cannot be asked for.
|
// was published, which PyPI cannot be asked for.
|
||||||
Source *Source
|
Source *Source
|
||||||
AsOf time.Time
|
AsOf time.Time
|
||||||
|
// AssetTag and AssetDir, for a Python engine that also ships a binary
|
||||||
|
// in its releases, are the release tag Assets are taken from, checked
|
||||||
|
// against the digests GitHub publishes, and the directory in the
|
||||||
|
// environment they are unpacked into.
|
||||||
|
AssetTag, AssetDir string
|
||||||
// FreeGB is the disk space the installation needs before it starts: a
|
// 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.
|
||||||
@@ -121,11 +126,13 @@ type Recipe struct {
|
|||||||
// from PyPI by uv, which is itself installed like llama.cpp: a GitHub release
|
// from PyPI by uv, which is itself installed like llama.cpp: a GitHub release
|
||||||
// checked against its published checksum.
|
// checked against its published checksum.
|
||||||
var Recipes = map[string]Recipe{
|
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},
|
"comfyui": {Candidates: comfyuiCandidates},
|
||||||
"uv": {Repo: "astral-sh/uv", Candidates: uvCandidates},
|
"strata": {Repo: StrataRepo, Candidates: strataCandidates},
|
||||||
|
"freetoken": {PyPI: "freetoken", Candidates: freetokenCandidates},
|
||||||
|
"uv": {Repo: "astral-sh/uv", Candidates: uvCandidates},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Installable are the engines this agent can install, as it tells the
|
// Installable are the engines this agent can install, as it tells the
|
||||||
@@ -172,6 +179,9 @@ func Builds(engine, version, arch string) ([]Build, error) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
for _, b := range bs {
|
for _, b := range bs {
|
||||||
|
if b.Python != "" {
|
||||||
|
return nil, fmt.Errorf("%s is a Python environment from PyPI, which an offline bundle does not carry", engine)
|
||||||
|
}
|
||||||
if !seen[b.Variant] {
|
if !seen[b.Variant] {
|
||||||
seen[b.Variant] = true
|
seen[b.Variant] = true
|
||||||
out = append(out, b)
|
out = append(out, b)
|
||||||
@@ -672,3 +682,133 @@ print("comfyui version", "` + version + `", "on torch", torch.__version__)`},
|
|||||||
{cu126, gpuNeeds{minDriverForCUDA12, 5.0}, "ComfyUI " + version + " on CUDA 12.6's torch"},
|
{cu126, gpuNeeds{minDriverForCUDA12, 5.0}, "ComfyUI " + version + " on CUDA 12.6's torch"},
|
||||||
}, h)
|
}, h)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Strata ---
|
||||||
|
|
||||||
|
// StrataRepo is where Strata is published: its source, and in each release
|
||||||
|
// the prebuilt engine for each CUDA.
|
||||||
|
const StrataRepo = "Niko1221/Strata"
|
||||||
|
|
||||||
|
// strataReleases are the Strata versions this agent installs: the source
|
||||||
|
// archive's digest, taken when it was added here and confirmed from a second
|
||||||
|
// machine, when the version was published, and the engine its release
|
||||||
|
// carries, whose files GitHub publishes digests for.
|
||||||
|
var strataReleases = map[string]struct {
|
||||||
|
sha256 string
|
||||||
|
published string
|
||||||
|
engine string
|
||||||
|
}{
|
||||||
|
"0.2.7": {"41ae861478f5e5d9506864f97dfb5dd7cfe6b83193300e2b0d4f04c0380e3af9", "2026-10-27T14:54:42Z", "0.1.52"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// strataCandidates are the Strata builds for a machine: its source, in a
|
||||||
|
// Python environment, with the prebuilt engine for the node's driver and
|
||||||
|
// cards — built for CUDA 13, Turing to Blackwell; or for CUDA 12.9, Pascal
|
||||||
|
// to Ada, which is what a V100, or a driver older than 580, gets.
|
||||||
|
func strataCandidates(version, variant string, h discovery.Host) ([]Build, error) {
|
||||||
|
if _, err := gpuArch("Strata", h); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if h.Arch != "amd64" {
|
||||||
|
return nil, NotHere{"Strata publishes its Linux CUDA engine for x86-64 only"}
|
||||||
|
}
|
||||||
|
rel, ok := strataReleases[version]
|
||||||
|
if !ok {
|
||||||
|
known := make([]string, 0, len(strataReleases))
|
||||||
|
for v := range strataReleases {
|
||||||
|
known = append(known, v)
|
||||||
|
}
|
||||||
|
slices.Sort(known)
|
||||||
|
return nil, fmt.Errorf("this agent knows the source digest of Strata %s, not of %s; "+
|
||||||
|
"install that one by hand and name it in agent.yaml (engines.strata.dir)",
|
||||||
|
strings.Join(known, ", "), version)
|
||||||
|
}
|
||||||
|
published, err := time.Parse(time.RFC3339, rel.published)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
build := func(cuda string) Build {
|
||||||
|
return Build{
|
||||||
|
Variant: cuda,
|
||||||
|
Python: pythonForEngines,
|
||||||
|
Source: &Source{
|
||||||
|
URL: "/" + StrataRepo + "/archive/refs/tags/v" + version + ".tar.gz",
|
||||||
|
SHA256: rel.sha256, Top: "Strata-" + version, Dir: "strata",
|
||||||
|
Requirements: "requirements.txt",
|
||||||
|
},
|
||||||
|
AsOf: published.Add(24 * time.Hour),
|
||||||
|
Assets: []string{"strata-engine-" + rel.engine + "-linux-x86_64-cuda-" + cuda + ".tar.gz"},
|
||||||
|
AssetTag: "v" + version,
|
||||||
|
AssetDir: "strata-engine",
|
||||||
|
// Strata is started as a script, so what the agent is given is
|
||||||
|
// the environment's interpreter; the proof is importing what its
|
||||||
|
// server needs, and finding its engine beside it.
|
||||||
|
Binary: "python",
|
||||||
|
VersionArgs: []string{"-c", `import os, sys, numpy, jinja2, regex
|
||||||
|
e = os.path.dirname(os.path.dirname(os.path.abspath(sys.executable)))
|
||||||
|
for f in ("strata/setup.py", "strata/serve/server.py", "strata-engine/strata"):
|
||||||
|
assert os.path.isfile(os.path.join(e, f)), f + " is missing"
|
||||||
|
print("strata version", "` + version + `", "engine", "` + rel.engine + `")`},
|
||||||
|
FreeGB: 5,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pick("Strata "+version, variant, []candidate{
|
||||||
|
{build("cuda13"), gpuNeeds{minDriverForCUDA13, 7.5}, "Strata " + version + " built for CUDA 13"},
|
||||||
|
// 12.9 rather than 12: its machine code is for 6.0 to 8.9 and its
|
||||||
|
// PTX was compiled by 12.9, so the driver must be 575.
|
||||||
|
{build("cuda12"), gpuNeeds{575, 6.0}, "Strata " + version + " built for CUDA 12.9"},
|
||||||
|
}, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FreeToken ---
|
||||||
|
|
||||||
|
// FreeTokenRepo is where FreeToken is developed; its builds are on PyPI.
|
||||||
|
const FreeTokenRepo = "FlashML-org/FreeToken"
|
||||||
|
|
||||||
|
// freetokenMinCompute is the oldest GPU FreeToken runs on. It says RTX 30
|
||||||
|
// and newer, and its kernels agree: on a Quadro RTX 6000 (7.5) version 0.2.1
|
||||||
|
// loaded gpt-oss-20b and failed its first prefill with no kernel image for
|
||||||
|
// the card, and in fp16 with fused experts its first request hit an illegal
|
||||||
|
// memory access. Its FP8 kernels need 8.9 and its NVFP4 ones 12.0, which
|
||||||
|
// the model's quantisation decides.
|
||||||
|
const freetokenMinCompute = 8.0
|
||||||
|
|
||||||
|
// freetokenToolkit finds the CUDA toolkit FreeToken compiles its kernels
|
||||||
|
// with; a variable for tests.
|
||||||
|
var freetokenToolkit = func() string { return discovery.CUDAToolkit(13) }
|
||||||
|
|
||||||
|
// freetokenCandidates are the FreeToken builds for a machine: its PyPI
|
||||||
|
// wheels with the kernel extras (FlashInfer, SGLang's kernels), CUDA 13,
|
||||||
|
// x86-64 only. A node of older cards is not one it is meant for, and is not
|
||||||
|
// reported as a fault; one of new cards without the toolkit is.
|
||||||
|
func freetokenCandidates(version, variant string, h discovery.Host) ([]Build, error) {
|
||||||
|
if _, err := gpuArch("FreeToken", h); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if h.Arch != "amd64" {
|
||||||
|
return nil, NotHere{"FreeToken publishes wheels for x86-64 only; this node is " + h.Arch}
|
||||||
|
}
|
||||||
|
if cc := h.MinComputeCapability; cc > 0 && cc < freetokenMinCompute {
|
||||||
|
return nil, NotHere{fmt.Sprintf("FreeToken runs on GPUs of compute capability %.1f and newer "+
|
||||||
|
"(RTX 30, A100 and later); this node has %.1f", freetokenMinCompute, cc)}
|
||||||
|
}
|
||||||
|
// It compiles kernels the first time a model loads, with nvcc, as its
|
||||||
|
// own install guide says; without a toolkit the installation looks
|
||||||
|
// finished and the first replica fails.
|
||||||
|
if freetokenToolkit() == "" {
|
||||||
|
return nil, fmt.Errorf("FreeToken compiles its kernels where it runs and needs the CUDA 13 toolkit's nvcc " +
|
||||||
|
"(in CUDA_HOME, /usr/local/cuda or /usr/local/cuda-13.*); install cuda-toolkit-13 from NVIDIA's repository")
|
||||||
|
}
|
||||||
|
b := Build{
|
||||||
|
Variant: "cu13",
|
||||||
|
Python: pythonForEngines,
|
||||||
|
Requirements: []string{"freetoken[accel]==" + version},
|
||||||
|
Binary: "ft",
|
||||||
|
Packages: compilerPackages,
|
||||||
|
FreeGB: 20,
|
||||||
|
GPUCheck: true,
|
||||||
|
}
|
||||||
|
return pick("FreeToken "+version, variant, []candidate{
|
||||||
|
{b, gpuNeeds{minDriverForCUDA13, freetokenMinCompute}, "FreeToken " + version},
|
||||||
|
}, h)
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package provision
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||||
|
)
|
||||||
|
|
||||||
|
// strataRelease is Strata's source archive as GitHub makes one, and its
|
||||||
|
// release's engine for CUDA 13, with the recipe pointed at the archive.
|
||||||
|
func strataRelease(t *testing.T, rel *release) {
|
||||||
|
t.Helper()
|
||||||
|
archive := tarGz(t, map[string]string{
|
||||||
|
"Strata-0.2.7/setup.py": "print('setup')\n",
|
||||||
|
"Strata-0.2.7/serve/server.py": "print('server')\n",
|
||||||
|
"Strata-0.2.7/requirements.txt": "numpy==2.5.3; python_version >= \"3.12\"\njinja2==3.1.6\n",
|
||||||
|
}, nil)
|
||||||
|
rel.assets["v0.2.7.tar.gz"] = archive
|
||||||
|
sum := sha256.Sum256(archive)
|
||||||
|
engine := tarGz(t, map[string]string{"strata": "\x7fELF", "libcudart.so.13": "lib"}, nil)
|
||||||
|
name := "strata-engine-0.1.52-linux-x86_64-cuda-cuda13.tar.gz"
|
||||||
|
rel.assets[name] = engine
|
||||||
|
esum := sha256.Sum256(engine)
|
||||||
|
rel.digests[name] = "sha256:" + hex.EncodeToString(esum[:])
|
||||||
|
|
||||||
|
old := strataReleases
|
||||||
|
strataReleases = map[string]struct {
|
||||||
|
sha256 string
|
||||||
|
published string
|
||||||
|
engine string
|
||||||
|
}{"0.2.7": {hex.EncodeToString(sum[:]), "2026-10-27T14:54:42Z", "0.1.52"}}
|
||||||
|
t.Cleanup(func() { strataReleases = old })
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrataIsInstalledWithItsEngineBesideItsCode(t *testing.T) {
|
||||||
|
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||||
|
in, rel := pythonInstaller(t, turingNode, sys)
|
||||||
|
strataRelease(t, rel)
|
||||||
|
|
||||||
|
got := in.Converge(t.Context(), []Want{{Engine: "strata", Version: "0.2.7"}})
|
||||||
|
if got[0].State != Ready {
|
||||||
|
t.Fatalf("not ready: %+v", got[0])
|
||||||
|
}
|
||||||
|
dir := filepath.Join(in.Root, "strata", "0.2.7-cuda13")
|
||||||
|
if got[0].Binary != filepath.Join(dir, "bin", "python") {
|
||||||
|
t.Errorf("binary %s, want the environment's interpreter", got[0].Binary)
|
||||||
|
}
|
||||||
|
for _, f := range []string{"strata/setup.py", "strata/serve/server.py", "strata-engine/strata", "strata-engine/libcudart.so.13"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
||||||
|
t.Errorf("%s is not in the environment: %v", f, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
all := strings.Join(sys.commands, "\n")
|
||||||
|
for _, want := range []string{"--exclude-newer 2026-10-28T14:54:42Z", "jinja2==3.1.6"} {
|
||||||
|
if !strings.Contains(all, want) {
|
||||||
|
t.Errorf("the installation never used %q:\n%s", want, all)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrataRefusesAnEngineWhoseDigestDoesNotMatch(t *testing.T) {
|
||||||
|
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||||
|
in, rel := pythonInstaller(t, turingNode, sys)
|
||||||
|
strataRelease(t, rel)
|
||||||
|
rel.digests["strata-engine-0.1.52-linux-x86_64-cuda-cuda13.tar.gz"] = "sha256:" + strings.Repeat("0", 64)
|
||||||
|
|
||||||
|
got := in.Converge(t.Context(), []Want{{Engine: "strata", Version: "0.2.7", Variant: "cuda13"}})
|
||||||
|
if got[0].State != Failed || !strings.Contains(got[0].Detail, "does not match the checksum") {
|
||||||
|
t.Fatalf("an engine that does not match its digest was not refused: %+v", got[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrataPicksTheEngineForTheCardsAndDriver(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
host discovery.Host
|
||||||
|
want []string
|
||||||
|
err string
|
||||||
|
}{
|
||||||
|
{"turing on 580", turingNode, []string{"cuda13", "cuda12"}, ""},
|
||||||
|
{"volta", discovery.Host{Arch: "amd64", NVIDIADriver: "580.82.07", MinComputeCapability: 7.0}, []string{"cuda12"}, ""},
|
||||||
|
{"driver 575", discovery.Host{Arch: "amd64", NVIDIADriver: "575.51.03", MinComputeCapability: 8.6}, []string{"cuda12"}, ""},
|
||||||
|
{"driver 570", discovery.Host{Arch: "amd64", NVIDIADriver: "570.86.10", MinComputeCapability: 8.6}, nil, "575"},
|
||||||
|
{"no gpu", discovery.Host{Arch: "amd64"}, nil, "no"},
|
||||||
|
{"arm", discovery.Host{Arch: "arm64", NVIDIADriver: "580.82.07", MinComputeCapability: 9.0}, nil, "x86-64"},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
bs, err := strataCandidates("0.2.7", "", tc.host)
|
||||||
|
if tc.err != "" {
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.err) {
|
||||||
|
t.Fatalf("got %v, %v; want an error saying %q", bs, err, tc.err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var got []string
|
||||||
|
for _, b := range bs {
|
||||||
|
got = append(got, b.Variant)
|
||||||
|
}
|
||||||
|
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||||
|
t.Errorf("builds %v, want %v", got, tc.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStrataIsNotInAnOfflineBundle(t *testing.T) {
|
||||||
|
if _, err := Builds("strata", "0.2.7", "amd64"); err == nil {
|
||||||
|
t.Fatal("an offline bundle would carry Strata, which needs PyPI")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,9 +54,13 @@ func NewHTTPHealth(timeout time.Duration, opts ...HTTPHealthOption) *HTTPHealth
|
|||||||
|
|
||||||
// Ready reports whether the engine at baseURL is serving model.
|
// Ready reports whether the engine at baseURL is serving model.
|
||||||
func (h *HTTPHealth) Ready(ctx context.Context, baseURL, model, apiKey string) error {
|
func (h *HTTPHealth) Ready(ctx context.Context, baseURL, model, apiKey string) error {
|
||||||
if err := h.get(ctx, baseURL+h.healthPath, apiKey, nil); err != nil {
|
var body healthBody
|
||||||
|
if err := h.get(ctx, baseURL+h.healthPath, apiKey, &body); err != nil {
|
||||||
return fmt.Errorf("health: %w", err)
|
return fmt.Errorf("health: %w", err)
|
||||||
}
|
}
|
||||||
|
if body.notReady() {
|
||||||
|
return fmt.Errorf("health: the engine says %q", body.Status)
|
||||||
|
}
|
||||||
if !h.requireName || model == "" {
|
if !h.requireName || model == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -98,5 +102,29 @@ func (h *HTTPHealth) get(ctx context.Context, url, apiKey string, out any) error
|
|||||||
if out == nil {
|
if out == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
if hb, ok := out.(*healthBody); ok {
|
||||||
|
// Most engines answer /health with nothing, or with text: only a
|
||||||
|
// JSON status says anything, and a body that is not one is not a
|
||||||
|
// failure.
|
||||||
|
_ = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(hb)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return json.NewDecoder(resp.Body).Decode(out)
|
return json.NewDecoder(resp.Body).Decode(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// healthBody is what an engine's /health may say in its body. Some answer
|
||||||
|
// 200 from the moment their HTTP server is up and say in the body that the
|
||||||
|
// model is still loading — FreeToken's {"status":"loading","phase":...} —
|
||||||
|
// and a model that lists itself in /v1/models meanwhile would be routed to.
|
||||||
|
type healthBody struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// notReady is a body that says the engine cannot serve yet, or has failed.
|
||||||
|
func (b healthBody) notReady() bool {
|
||||||
|
switch b.Status {
|
||||||
|
case "loading", "starting", "initializing", "warming_up", "error", "unhealthy":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -103,3 +103,29 @@ func TestTheHealthCheckSendsTheEnginesKey(t *testing.T) {
|
|||||||
t.Error("without the key the engine refused, and that read as ready")
|
t.Error("without the key the engine refused, and that read as ready")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestNotReadyWhileTheHealthBodySaysLoading(t *testing.T) {
|
||||||
|
// FreeToken answers 200 from the moment its HTTP server is up and lists
|
||||||
|
// its model meanwhile; only the body says the weights are still loading.
|
||||||
|
body := `{"status":"loading","phase":"weights","progress":{"done_bytes":1,"total_bytes":9},"model":"glm"}`
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { io.WriteString(w, body) })
|
||||||
|
mux.HandleFunc("GET /v1/models", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
io.WriteString(w, `{"data":[{"id":"glm"}]}`)
|
||||||
|
})
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
h := NewHTTPHealth(time.Second)
|
||||||
|
if err := h.Ready(t.Context(), srv.URL, "glm", ""); err == nil || !strings.Contains(err.Error(), "loading") {
|
||||||
|
t.Errorf("a loading engine was reported ready, or not said to be loading: %v", err)
|
||||||
|
}
|
||||||
|
body = `{"status":"ok","model":"glm","uptime_s":3}`
|
||||||
|
if err := h.Ready(t.Context(), srv.URL, "glm", ""); err != nil {
|
||||||
|
t.Errorf("a serving engine was reported unready: %v", err)
|
||||||
|
}
|
||||||
|
// Text, as some engines answer: not a status, and not a failure.
|
||||||
|
body = "OK"
|
||||||
|
if err := h.Ready(t.Context(), srv.URL, "glm", ""); err != nil {
|
||||||
|
t.Errorf("a plain-text health answer was taken for a failure: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,3 +64,71 @@ func isZombie(pid int) bool {
|
|||||||
}
|
}
|
||||||
return b[i+2] == 'Z'
|
return b[i+2] == 'Z'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// strays are a process's descendants that left its group, as an engine that
|
||||||
|
// starts its worker in a session of its own does (Strata): signalling the
|
||||||
|
// group misses them, and one left behind holds the card and the RAM the next
|
||||||
|
// replica needs. Found before the group is signalled, while they are still
|
||||||
|
// its descendants: after, they belong to init. Linux only; elsewhere none.
|
||||||
|
func strays(pid int) []int {
|
||||||
|
entries, err := os.ReadDir("/proc")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
parent := map[int]int{}
|
||||||
|
group := map[int]int{}
|
||||||
|
for _, e := range entries {
|
||||||
|
p, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if pp, pg, ok := parentAndGroup(p); ok {
|
||||||
|
parent[p], group[p] = pp, pg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var out []int
|
||||||
|
for p := range parent {
|
||||||
|
if group[p] == pid || p == pid {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Bounded: no engine's tree is this deep, and a pid reused while
|
||||||
|
// /proc was read must not loop.
|
||||||
|
for q, n := parent[p], 0; q > 1 && n < 32; q, n = parent[q], n+1 {
|
||||||
|
if q == pid {
|
||||||
|
out = append(out, p)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// parentAndGroup reads a process's parent and group from /proc.
|
||||||
|
func parentAndGroup(pid int) (int, int, bool) {
|
||||||
|
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
i := bytes.LastIndexByte(b, ')')
|
||||||
|
if i < 0 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
f := strings.Fields(string(b[i+1:]))
|
||||||
|
if len(f) < 3 {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
|
pp, err1 := strconv.Atoi(f[1])
|
||||||
|
pg, err2 := strconv.Atoi(f[2])
|
||||||
|
return pp, pg, err1 == nil && err2 == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// signalStrays signals each stray, and its own group when it leads one.
|
||||||
|
func signalStrays(pids []int, sig syscall.Signal) {
|
||||||
|
for _, p := range pids {
|
||||||
|
if _, pg, ok := parentAndGroup(p); ok && pg == p {
|
||||||
|
_ = syscall.Kill(-p, sig)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = syscall.Kill(p, sig)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -582,10 +582,24 @@ func (s *Supervisor) Stop(ctx context.Context, id string) (err error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Signal the group, not the process: engines fork workers that hold GPU
|
// Signal the group, not the process: engines fork workers that hold GPU
|
||||||
// memory, and those outlive a signal sent only to the leader.
|
// memory, and those outlive a signal sent only to the leader. And what
|
||||||
|
// left the group, found while it is still the leader's.
|
||||||
|
left := strays(pid)
|
||||||
if err := killGroup(pid, syscall.SIGTERM); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
if err := killGroup(pid, syscall.SIGTERM); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||||
s.log.Warn("SIGTERM failed", "shard", id, "err", err)
|
s.log.Warn("SIGTERM failed", "shard", id, "err", err)
|
||||||
}
|
}
|
||||||
|
signalStrays(left, syscall.SIGTERM)
|
||||||
|
defer func() {
|
||||||
|
// Given as long as the group to go; then killed.
|
||||||
|
deadline := time.Now().Add(5 * time.Second)
|
||||||
|
for time.Now().Before(deadline) && slices.ContainsFunc(left, processAlive) {
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
if slices.ContainsFunc(left, processAlive) {
|
||||||
|
s.log.Warn("killing an engine's processes that left its group", "shard", id, "pids", left)
|
||||||
|
signalStrays(left, syscall.SIGKILL)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
// force kills the group outright. exec.Cmd's cancel does this for shards we
|
// force kills the group outright. exec.Cmd's cancel does this for shards we
|
||||||
// started; an adopted shard has none, so signal its group directly.
|
// started; an adopted shard has none, so signal its group directly.
|
||||||
@@ -598,6 +612,7 @@ func (s *Supervisor) Stop(ctx context.Context, id string) (err error) {
|
|||||||
if err := killGroup(pid, syscall.SIGKILL); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
if err := killGroup(pid, syscall.SIGKILL); err != nil && !errors.Is(err, os.ErrProcessDone) {
|
||||||
s.log.Warn("SIGKILL failed", "shard", id, "err", err)
|
s.log.Warn("SIGKILL failed", "shard", id, "err", err)
|
||||||
}
|
}
|
||||||
|
signalStrays(left, syscall.SIGKILL)
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -981,3 +982,38 @@ func TestAnOutputFileIsEmptiedPastItsBound(t *testing.T) {
|
|||||||
t.Errorf("a line written after the file was emptied was lost: %v", sh.Logs(8))
|
t.Errorf("a line written after the file was emptied was lost: %v", sh.Logs(8))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An engine's worker in a session of its own, as Strata's is, is not in the
|
||||||
|
// group a stop signals: it is found and stopped as well.
|
||||||
|
func TestStopKillsAWorkerThatLeftTheGroup(t *testing.T) {
|
||||||
|
if _, err := os.Stat("/proc/self/stat"); err != nil {
|
||||||
|
t.Skip("needs /proc")
|
||||||
|
}
|
||||||
|
if _, err := exec.LookPath("setsid"); err != nil {
|
||||||
|
t.Skip("needs setsid")
|
||||||
|
}
|
||||||
|
s, _ := newSupervisor(t)
|
||||||
|
marker := filepath.Join(t.TempDir(), "worker.pid")
|
||||||
|
script := fmt.Sprintf(`setsid sh -c 'echo $$ > %s; sleep 30' & sleep 30`, marker)
|
||||||
|
if _, err := s.Start(t.Context(), "a", engines.ShardSpec{}, shellCommand(script)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var worker int
|
||||||
|
for deadline := time.Now().Add(3 * time.Second); time.Now().Before(deadline) && worker == 0; time.Sleep(10 * time.Millisecond) {
|
||||||
|
if b, err := os.ReadFile(marker); err == nil {
|
||||||
|
_, _ = fmt.Sscanf(strings.TrimSpace(string(b)), "%d", &worker)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if worker == 0 {
|
||||||
|
t.Fatal("the worker never started")
|
||||||
|
}
|
||||||
|
if err := s.Stop(t.Context(), "a"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for deadline := time.Now().Add(8 * time.Second); time.Now().Before(deadline); time.Sleep(50 * time.Millisecond) {
|
||||||
|
if !processAlive(worker) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("the worker %d that left the group survived the stop", worker)
|
||||||
|
}
|
||||||
@@ -35,12 +35,25 @@ func (a *Agent) upgrade(ctx context.Context, m *llmgwv1.UpgradeAgent, out chan<-
|
|||||||
select {
|
select {
|
||||||
case out <- &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Upgrade{Upgrade: reply}}:
|
case out <- &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Upgrade{Upgrade: reply}}:
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
|
case <-time.After(5 * time.Second):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// One at a time: a second request while one downloads would race it to
|
||||||
|
// the same file.
|
||||||
|
if !a.upgrading.CompareAndSwap(false, true) {
|
||||||
|
reply.Error = "an upgrade is already under way on this node"
|
||||||
|
send()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer a.upgrading.Store(false)
|
||||||
a.log.Info("upgrading", "to", m.GetVersion(), "from", a.cfg.Version)
|
a.log.Info("upgrading", "to", m.GetVersion(), "from", a.cfg.Version)
|
||||||
self, err := upgradeTarget()
|
self, err := upgradeTarget()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
dctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
|
// Not the stream's context: a stream that breaks while the release
|
||||||
|
// downloads — a control plane restarting, leadership moving — cancelled
|
||||||
|
// the upgrade with nobody told. The node registering again on the
|
||||||
|
// version is what the control plane watches for.
|
||||||
|
dctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), upgradeDownload)
|
||||||
_, err = release.Install(dctx, nil, m.GetReleases(), m.GetVersion(), runtime.GOOS, runtime.GOARCH, self)
|
_, err = release.Install(dctx, nil, m.GetReleases(), m.GetVersion(), runtime.GOOS, runtime.GOARCH, self)
|
||||||
cancel()
|
cancel()
|
||||||
}
|
}
|
||||||
@@ -53,6 +66,13 @@ func (a *Agent) upgrade(ctx context.Context, m *llmgwv1.UpgradeAgent, out chan<-
|
|||||||
send()
|
send()
|
||||||
a.log.Info("upgraded; restarting into the new version", "to", m.GetVersion())
|
a.log.Info("upgraded; restarting into the new version", "to", m.GetVersion())
|
||||||
// A moment for the reply to leave before the stream goes.
|
// A moment for the reply to leave before the stream goes.
|
||||||
time.Sleep(500 * time.Millisecond)
|
time.Sleep(upgradeLinger)
|
||||||
exitForUpgrade()
|
exitForUpgrade()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upgradeDownload bounds an upgrade's download; the control plane waits
|
||||||
|
// longer (agentsvc.UpgradeWait). upgradeLinger is the moment its answer has
|
||||||
|
// to leave before the agent exits; a variable so tests need not wait it.
|
||||||
|
const upgradeDownload = 10 * time.Minute
|
||||||
|
|
||||||
|
var upgradeLinger = 2 * time.Second
|
||||||
+137
-15
@@ -38,8 +38,14 @@ type Progress struct {
|
|||||||
Source string
|
Source string
|
||||||
BytesDone int64
|
BytesDone int64
|
||||||
BytesTotal int64
|
BytesTotal int64
|
||||||
|
// Phase is empty while the bytes arrive, PhaseChecking while a set's
|
||||||
|
// files are checked against the mirror's checksums.
|
||||||
|
Phase string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PhaseChecking is a set's files being checked, after they arrived.
|
||||||
|
const PhaseChecking = "checking"
|
||||||
|
|
||||||
// Percent is how far along, or -1 when the total is unknown.
|
// Percent is how far along, or -1 when the total is unknown.
|
||||||
func (p Progress) Percent() int {
|
func (p Progress) Percent() int {
|
||||||
if p.BytesTotal <= 0 {
|
if p.BytesTotal <= 0 {
|
||||||
@@ -90,14 +96,27 @@ var ErrNotLocal = errors.New("the weights are not on this node and the source sa
|
|||||||
// already cached, downloaded, or downloaded by another shard this one waited
|
// already cached, downloaded, or downloaded by another shard this one waited
|
||||||
// for — since that is most of the difference between a replica that starts
|
// for — since that is most of the difference between a replica that starts
|
||||||
// in a minute and one that takes an hour.
|
// in a minute and one that takes an hour.
|
||||||
func (f *Fetcher) Ensure(ctx context.Context, source string) (path string, err error) {
|
//
|
||||||
|
// A source in a pickle format, or a set with such a file in it, is refused
|
||||||
|
// before anything is fetched unless allowPickle; see Pickled.
|
||||||
|
func (f *Fetcher) Ensure(ctx context.Context, source string, allowPickle bool) (path string, err error) {
|
||||||
|
return f.EnsureChecked(ctx, source, allowPickle, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureChecked is Ensure for a file whose sha256 the registry published: a
|
||||||
|
// copy that is not it, downloaded or cached, is refused rather than served.
|
||||||
|
// An empty sha256 checks nothing.
|
||||||
|
func (f *Fetcher) EnsureChecked(ctx context.Context, source string, allowPickle bool, sha256 string) (path string, err error) {
|
||||||
ctx, span := obs.Tracer(tracerName).Start(ctx, "fetch weights",
|
ctx, span := obs.Tracer(tracerName).Start(ctx, "fetch weights",
|
||||||
trace.WithAttributes(attribute.String("llmgw.weights.source", redact(source))))
|
trace.WithAttributes(attribute.String("llmgw.weights.source", redact(source))))
|
||||||
defer func() {
|
defer func() {
|
||||||
span.SetAttributes(attribute.String("llmgw.weights.path", path))
|
span.SetAttributes(attribute.String("llmgw.weights.path", path))
|
||||||
obs.End(span, err)
|
obs.End(span, err)
|
||||||
}()
|
}()
|
||||||
return f.ensure(ctx, source)
|
if !allowPickle && !IsSet(source) && Pickled(source) {
|
||||||
|
return "", refusePickle(source)
|
||||||
|
}
|
||||||
|
return f.ensure(ctx, source, allowPickle, strings.ToLower(sha256))
|
||||||
}
|
}
|
||||||
|
|
||||||
// tracerName names the spans this package opens.
|
// tracerName names the spans this package opens.
|
||||||
@@ -119,7 +138,7 @@ func redact(source string) string {
|
|||||||
return u.String()
|
return u.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) {
|
func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool, want string) (string, error) {
|
||||||
if source == "" {
|
if source == "" {
|
||||||
return "", fmt.Errorf("no source given")
|
return "", fmt.Errorf("no source given")
|
||||||
}
|
}
|
||||||
@@ -127,7 +146,11 @@ func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) {
|
|||||||
// file, and a set has to be handled before any of it.
|
// file, and a set has to be handled before any of it.
|
||||||
if IsSet(source) {
|
if IsSet(source) {
|
||||||
outcome(ctx, "set")
|
outcome(ctx, "set")
|
||||||
return f.ensureSet(ctx, source)
|
// Shared like a single file: four replicas placed on one node's
|
||||||
|
// four cards want the same set at the same instant, and each
|
||||||
|
// fetching it wrote the same .part files over one another — three
|
||||||
|
// failed renaming a file another had already renamed.
|
||||||
|
return f.shared(ctx, source, func() (string, error) { return f.ensureSet(ctx, source, allowPickle) })
|
||||||
}
|
}
|
||||||
if !isRemote(source) {
|
if !isRemote(source) {
|
||||||
if !isPath(source) {
|
if !isPath(source) {
|
||||||
@@ -150,10 +173,24 @@ func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) {
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(target); err == nil {
|
if _, err := os.Stat(target); err == nil {
|
||||||
outcome(ctx, "cached")
|
if ok, err := f.cachedIs(target, want); err != nil {
|
||||||
return target, nil
|
return "", err
|
||||||
|
} else if ok {
|
||||||
|
outcome(ctx, "cached")
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
// Not the file the catalog names: fetched again below, and checked.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return f.shared(ctx, source, func() (string, error) {
|
||||||
|
outcome(ctx, "downloaded")
|
||||||
|
return f.fetchChecked(ctx, source, target, want)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// shared runs fetch once for every caller asking for source at the same
|
||||||
|
// time; the others wait for its answer.
|
||||||
|
func (f *Fetcher) shared(ctx context.Context, source string, fetch func() (string, error)) (string, error) {
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
if f.inflight == nil {
|
if f.inflight == nil {
|
||||||
f.inflight = map[string]*fetchCall{}
|
f.inflight = map[string]*fetchCall{}
|
||||||
@@ -172,8 +209,7 @@ func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) {
|
|||||||
f.inflight[source] = call
|
f.inflight[source] = call
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|
||||||
outcome(ctx, "downloaded")
|
call.path, call.err = fetch()
|
||||||
call.path, call.err = f.fetch(ctx, source, target)
|
|
||||||
close(call.done)
|
close(call.done)
|
||||||
|
|
||||||
f.mu.Lock()
|
f.mu.Lock()
|
||||||
@@ -184,9 +220,83 @@ func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) {
|
|||||||
|
|
||||||
// fetch downloads a source to a target, resuming and verifying.
|
// fetch downloads a source to a target, resuming and verifying.
|
||||||
func (f *Fetcher) fetch(ctx context.Context, source, target string) (string, error) {
|
func (f *Fetcher) fetch(ctx context.Context, source, target string) (string, error) {
|
||||||
return f.fetchAs(ctx, source, source, target)
|
return f.fetchAs(ctx, source, source, target, setShare{})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchChecked is fetch for a file whose sha256 is known: a download that
|
||||||
|
// is not it is deleted and refused, naming both, and one that is has its
|
||||||
|
// checksum kept beside it so a cached copy is not read again at each start.
|
||||||
|
func (f *Fetcher) fetchChecked(ctx context.Context, source, target, want string) (string, error) {
|
||||||
|
got, err := f.fetch(ctx, source, target)
|
||||||
|
if err != nil || want == "" {
|
||||||
|
return got, err
|
||||||
|
}
|
||||||
|
sum, err := fileSHA256(target)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if sum != want {
|
||||||
|
_ = os.Remove(target)
|
||||||
|
return "", fmt.Errorf("%s arrived with sha256 %s, and the registry published %s for it: refused and deleted",
|
||||||
|
redact(source), sum, want)
|
||||||
|
}
|
||||||
|
_ = writeChecksum(target, sum)
|
||||||
|
return target, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cachedIs says whether a cached file is the one whose sha256 is want: by
|
||||||
|
// the checksum kept beside it, or worked out once and then kept. One that
|
||||||
|
// is not is deleted, to be fetched again. Any file is, when want is empty.
|
||||||
|
func (f *Fetcher) cachedIs(target, want string) (bool, error) {
|
||||||
|
kept := readChecksum(target)
|
||||||
|
if want == "" || kept == want {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if kept != "" {
|
||||||
|
// Checked when it was fetched, as another file: the same address
|
||||||
|
// gives the same bytes again, and another model may be serving
|
||||||
|
// these. Refused, and left.
|
||||||
|
return false, fmt.Errorf("the copy of this file here has sha256 %s, as fetched, and the catalog names %s: refused",
|
||||||
|
kept, want)
|
||||||
|
}
|
||||||
|
sum, err := fileSHA256(target)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if sum == want {
|
||||||
|
_ = writeChecksum(target, sum)
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if f.Log != nil {
|
||||||
|
f.Log.Warn("a cached file is not the one the catalog names; fetching it again",
|
||||||
|
"path", target, "sha256", sum, "want", want)
|
||||||
|
}
|
||||||
|
_ = os.Remove(checksumPath(target))
|
||||||
|
return false, os.Remove(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// checksumPath is where a cached file's sha256 is kept: beside it, hidden,
|
||||||
|
// so no scan takes it for weights.
|
||||||
|
func checksumPath(file string) string {
|
||||||
|
return filepath.Join(filepath.Dir(file), "."+filepath.Base(file)+".sha256")
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeChecksum(file, sum string) error {
|
||||||
|
return os.WriteFile(checksumPath(file), []byte(sum+"\n"), 0o644) //nolint:gosec // a checksum, for anyone to read
|
||||||
|
}
|
||||||
|
|
||||||
|
func readChecksum(file string) string {
|
||||||
|
b, err := os.ReadFile(checksumPath(file)) //nolint:gosec // beside a file this fetcher wrote
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChecksumPath is where the sha256 of a cached file is kept, for whoever
|
||||||
|
// deletes the file to delete with it.
|
||||||
|
func ChecksumPath(file string) string { return checksumPath(file) }
|
||||||
|
|
||||||
// fetchAs downloads one URL but reports progress under another name.
|
// fetchAs downloads one URL but reports progress under another name.
|
||||||
//
|
//
|
||||||
// The two differ for a set: the shards waiting on it asked for the version, not
|
// The two differ for a set: the shards waiting on it asked for the version, not
|
||||||
@@ -195,7 +305,7 @@ func (f *Fetcher) fetch(ctx context.Context, source, target string) (string, err
|
|||||||
//
|
//
|
||||||
// Each file is a "download" span, with how many bytes it took and where a
|
// Each file is a "download" span, with how many bytes it took and where a
|
||||||
// resumed one started from.
|
// resumed one started from.
|
||||||
func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string) (_ string, err error) {
|
func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string, share setShare) (_ string, err error) {
|
||||||
ctx, span := obs.Tracer(tracerName).Start(ctx, "download",
|
ctx, span := obs.Tracer(tracerName).Start(ctx, "download",
|
||||||
trace.WithSpanKind(trace.SpanKindClient),
|
trace.WithSpanKind(trace.SpanKindClient),
|
||||||
trace.WithAttributes(attribute.String("llmgw.weights.source", redact(source))))
|
trace.WithAttributes(attribute.String("llmgw.weights.source", redact(source))))
|
||||||
@@ -259,8 +369,8 @@ func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
total := existing + resp.ContentLength
|
total := existing + resp.ContentLength
|
||||||
f.report(Progress{Source: reportAs, BytesDone: existing, BytesTotal: total})
|
f.report(share.of(Progress{Source: reportAs, BytesDone: existing, BytesTotal: total}))
|
||||||
written, err := f.copyWithProgress(ctx, out, resp.Body, reportAs, existing, total)
|
written, err := f.copyWithProgress(ctx, out, resp.Body, reportAs, existing, total, share)
|
||||||
span.SetAttributes(
|
span.SetAttributes(
|
||||||
attribute.Int64("llmgw.weights.resumed_from", existing),
|
attribute.Int64("llmgw.weights.resumed_from", existing),
|
||||||
attribute.Int64("llmgw.weights.bytes", written),
|
attribute.Int64("llmgw.weights.bytes", written),
|
||||||
@@ -284,7 +394,7 @@ func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (f *Fetcher) copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader,
|
func (f *Fetcher) copyWithProgress(ctx context.Context, dst io.Writer, src io.Reader,
|
||||||
source string, done, total int64,
|
source string, done, total int64, share setShare,
|
||||||
) (int64, error) {
|
) (int64, error) {
|
||||||
buf := make([]byte, 4<<20)
|
buf := make([]byte, 4<<20)
|
||||||
last := time.Now()
|
last := time.Now()
|
||||||
@@ -302,12 +412,12 @@ func (f *Fetcher) copyWithProgress(ctx context.Context, dst io.Writer, src io.Re
|
|||||||
// hundred gigabytes is twenty-five thousand callbacks, and nobody
|
// hundred gigabytes is twenty-five thousand callbacks, and nobody
|
||||||
// needs to be told that often.
|
// needs to be told that often.
|
||||||
if time.Since(last) > 2*time.Second {
|
if time.Since(last) > 2*time.Second {
|
||||||
f.report(Progress{Source: source, BytesDone: done, BytesTotal: total})
|
f.report(share.of(Progress{Source: source, BytesDone: done, BytesTotal: total}))
|
||||||
last = time.Now()
|
last = time.Now()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if errors.Is(err, io.EOF) {
|
if errors.Is(err, io.EOF) {
|
||||||
f.report(Progress{Source: source, BytesDone: done, BytesTotal: total})
|
f.report(share.of(Progress{Source: source, BytesDone: done, BytesTotal: total}))
|
||||||
return done, nil
|
return done, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -316,6 +426,18 @@ func (f *Fetcher) copyWithProgress(ctx context.Context, dst io.Writer, src io.Re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setShare places one file's download within its set's: the bytes of the
|
||||||
|
// files before it, and the whole set's. Zero for a file on its own.
|
||||||
|
type setShare struct{ before, total int64 }
|
||||||
|
|
||||||
|
// of is a file's progress as the set's.
|
||||||
|
func (s setShare) of(p Progress) Progress {
|
||||||
|
if s.total > 0 {
|
||||||
|
p.BytesDone, p.BytesTotal = s.before+p.BytesDone, s.total
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Fetcher) report(p Progress) {
|
func (f *Fetcher) report(p Progress) {
|
||||||
if f.OnProgress != nil {
|
if f.OnProgress != nil {
|
||||||
f.OnProgress(p)
|
f.OnProgress(p)
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package weights
|
package weights
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -37,7 +39,7 @@ func TestALocalPathIsUsedNeverCopied(t *testing.T) {
|
|||||||
}
|
}
|
||||||
f := &Fetcher{CacheDir: t.TempDir()}
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
|
|
||||||
got, err := f.Ensure(t.Context(), path)
|
got, err := f.Ensure(t.Context(), path, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -53,7 +55,7 @@ func TestAMissingLocalPathSaysWhatIsWrong(t *testing.T) {
|
|||||||
// The commonest setup mistake, and "no such file" from an engine twenty
|
// The commonest setup mistake, and "no such file" from an engine twenty
|
||||||
// minutes into a load is a poor way to learn it.
|
// minutes into a load is a poor way to learn it.
|
||||||
f := &Fetcher{CacheDir: t.TempDir()}
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
_, err := f.Ensure(t.Context(), filepath.Join(t.TempDir(), "absent.gguf"))
|
_, err := f.Ensure(t.Context(), filepath.Join(t.TempDir(), "absent.gguf"), false)
|
||||||
if !errors.Is(err, ErrNotLocal) {
|
if !errors.Is(err, ErrNotLocal) {
|
||||||
t.Fatalf("err = %v, want ErrNotLocal", err)
|
t.Fatalf("err = %v, want ErrNotLocal", err)
|
||||||
}
|
}
|
||||||
@@ -67,7 +69,7 @@ func TestARemoteFileIsFetchedOnceAndReused(t *testing.T) {
|
|||||||
srv := serveBytes(t, []byte("the weights"), &hits)
|
srv := serveBytes(t, []byte("the weights"), &hits)
|
||||||
f := &Fetcher{CacheDir: t.TempDir()}
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
|
|
||||||
first, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
first, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -76,7 +78,7 @@ func TestARemoteFileIsFetchedOnceAndReused(t *testing.T) {
|
|||||||
t.Fatalf("fetched content = %q, err %v", body, err)
|
t.Fatalf("fetched content = %q, err %v", body, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
second, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
second, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -103,7 +105,7 @@ func TestConcurrentRequestsProduceOneDownload(t *testing.T) {
|
|||||||
wg.Add(1)
|
wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
paths[i], errs[i] = f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
paths[i], errs[i] = f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
@@ -139,7 +141,7 @@ func TestAPartialDownloadIsNeverVisibleAsTheModel(t *testing.T) {
|
|||||||
|
|
||||||
cache := t.TempDir()
|
cache := t.TempDir()
|
||||||
f := &Fetcher{CacheDir: cache}
|
f := &Fetcher{CacheDir: cache}
|
||||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf"); err == nil {
|
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false); err == nil {
|
||||||
t.Fatal("a truncated download was reported as success")
|
t.Fatal("a truncated download was reported as success")
|
||||||
}
|
}
|
||||||
entries, _ := os.ReadDir(cache)
|
entries, _ := os.ReadDir(cache)
|
||||||
@@ -169,7 +171,7 @@ func TestAnInterruptedDownloadResumes(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
got, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -232,7 +234,7 @@ func TestProgressIsReportedWithAPercentage(t *testing.T) {
|
|||||||
CacheDir: t.TempDir(),
|
CacheDir: t.TempDir(),
|
||||||
OnProgress: func(p Progress) { seen = append(seen, p) },
|
OnProgress: func(p Progress) { seen = append(seen, p) },
|
||||||
}
|
}
|
||||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf"); err != nil {
|
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(seen) == 0 {
|
if len(seen) == 0 {
|
||||||
@@ -254,7 +256,7 @@ func TestPercentIsUnknownRatherThanZeroWithoutATotal(t *testing.T) {
|
|||||||
|
|
||||||
func TestNoCacheDirectoryIsAClearRefusal(t *testing.T) {
|
func TestNoCacheDirectoryIsAClearRefusal(t *testing.T) {
|
||||||
f := &Fetcher{}
|
f := &Fetcher{}
|
||||||
_, err := f.Ensure(t.Context(), "https://example.invalid/model.gguf")
|
_, err := f.Ensure(t.Context(), "https://example.invalid/model.gguf", false)
|
||||||
if err == nil || !strings.Contains(err.Error(), "cache directory") {
|
if err == nil || !strings.Contains(err.Error(), "cache directory") {
|
||||||
t.Errorf("err = %v, want a complaint about the cache directory", err)
|
t.Errorf("err = %v, want a complaint about the cache directory", err)
|
||||||
}
|
}
|
||||||
@@ -269,7 +271,7 @@ func TestABareModelIdIsLeftToTheEngine(t *testing.T) {
|
|||||||
"Qwen/Qwen2.5-7B-Instruct",
|
"Qwen/Qwen2.5-7B-Instruct",
|
||||||
"meta-llama/Llama-3.3-70B-Instruct",
|
"meta-llama/Llama-3.3-70B-Instruct",
|
||||||
} {
|
} {
|
||||||
got, err := f.Ensure(t.Context(), id)
|
got, err := f.Ensure(t.Context(), id, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("%s was refused: %v", id, err)
|
t.Errorf("%s was refused: %v", id, err)
|
||||||
}
|
}
|
||||||
@@ -283,7 +285,79 @@ func TestAnAbsolutePathThatIsMissingIsStillAnError(t *testing.T) {
|
|||||||
// The other half: an operator writing a path means a file, and a missing
|
// The other half: an operator writing a path means a file, and a missing
|
||||||
// one should be said plainly rather than handed to an engine.
|
// one should be said plainly rather than handed to an engine.
|
||||||
f := &Fetcher{CacheDir: t.TempDir()}
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
if _, err := f.Ensure(t.Context(), "/models/definitely-not-here.gguf"); !errors.Is(err, ErrNotLocal) {
|
if _, err := f.Ensure(t.Context(), "/models/definitely-not-here.gguf", false); !errors.Is(err, ErrNotLocal) {
|
||||||
t.Errorf("err = %v, want ErrNotLocal", err)
|
t.Errorf("err = %v, want ErrNotLocal", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A file whose sha256 the registry published is served only as that file:
|
||||||
|
// changed upstream after the model was added, it is refused with both
|
||||||
|
// checksums named, and a cached copy that is not it is fetched again.
|
||||||
|
func TestAFileThatIsNotWhatTheRegistryPublishedIsRefused(t *testing.T) {
|
||||||
|
body := "the weights as published"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
sum := sha256.Sum256([]byte(body))
|
||||||
|
want := hex.EncodeToString(sum[:])
|
||||||
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
|
src := srv.URL + "/model.gguf"
|
||||||
|
|
||||||
|
path, err := f.EnsureChecked(t.Context(), src, false, strings.ToUpper(want))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if readChecksum(path) != want {
|
||||||
|
t.Errorf("the checksum was not kept beside it: %q", readChecksum(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
// The cached copy changed under it: fetched again, and the right one kept.
|
||||||
|
if err := os.WriteFile(path, []byte("tampered"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = os.Remove(checksumPath(path))
|
||||||
|
if _, err := f.EnsureChecked(t.Context(), src, false, want); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(path); string(b) != body {
|
||||||
|
t.Errorf("the cached copy that was not it was served: %q", b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upstream changed after the model was added.
|
||||||
|
body = "weights someone replaced"
|
||||||
|
f2 := &Fetcher{CacheDir: t.TempDir()}
|
||||||
|
_, err = f2.EnsureChecked(t.Context(), src, false, want)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), want) || !strings.Contains(err.Error(), "sha256") {
|
||||||
|
t.Fatalf("not refused, or without both checksums: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(CachePathFor(f2.CacheDir, src)); !os.IsNotExist(err) {
|
||||||
|
t.Error("the file that was not it was kept")
|
||||||
|
}
|
||||||
|
// With nothing to check against, it is fetched as before.
|
||||||
|
if _, err := f2.Ensure(t.Context(), src, false); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A cached file checked when it was fetched, that another model names with
|
||||||
|
// another checksum, is refused and left for the model it is.
|
||||||
|
func TestACheckedFileAnotherChecksumNamesIsRefusedAndLeft(t *testing.T) {
|
||||||
|
body := "the weights"
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(body)) }))
|
||||||
|
defer srv.Close()
|
||||||
|
sum := sha256.Sum256([]byte(body))
|
||||||
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
|
src := srv.URL + "/m.gguf"
|
||||||
|
path, err := f.EnsureChecked(t.Context(), src, false, hex.EncodeToString(sum[:]))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
wrong := strings.Repeat("0", 64)
|
||||||
|
if _, err := f.EnsureChecked(t.Context(), src, false, wrong); err == nil || !strings.Contains(err.Error(), wrong) {
|
||||||
|
t.Fatalf("not refused: %v", err)
|
||||||
|
}
|
||||||
|
if b, err := os.ReadFile(path); err != nil || string(b) != body {
|
||||||
|
t.Errorf("the checked file was not left: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package weights
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Weights in Python's pickle format are a program as much as a file: loading
|
||||||
|
// one runs whatever it says, as the engine's user, on the node. That is how
|
||||||
|
// the malicious models found on Hugging Face work, and nothing in the file's
|
||||||
|
// name or size says so. safetensors and GGUF are only tensors and are read,
|
||||||
|
// never run, so those are what a node fetches unless the catalog says
|
||||||
|
// otherwise for a model, with allow_pickle, on purpose.
|
||||||
|
|
||||||
|
// pickleExts are the extensions of PyTorch's pickle formats, and of pickle
|
||||||
|
// itself. A .bin beside safetensors is the same model in pickle form.
|
||||||
|
var pickleExts = []string{".bin", ".pt", ".pt2", ".pth", ".ckpt", ".pkl", ".pickle"}
|
||||||
|
|
||||||
|
// Pickled reports whether a file name or URL is in a pickle format.
|
||||||
|
func Pickled(name string) bool {
|
||||||
|
// A query or a fragment is not the file's name, on a URL or an hf:
|
||||||
|
// source alike: model.bin?download=true is still model.bin.
|
||||||
|
if i := strings.IndexAny(name, "?#"); i >= 0 {
|
||||||
|
name = name[:i]
|
||||||
|
}
|
||||||
|
ext := strings.ToLower(path.Ext(name))
|
||||||
|
for _, e := range pickleExts {
|
||||||
|
if ext == e {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrPickle is a source refused for being in a pickle format.
|
||||||
|
var ErrPickle = errors.New("weights in a pickle format run code when they are loaded")
|
||||||
|
|
||||||
|
func refusePickle(name string) error {
|
||||||
|
return fmt.Errorf("%w: %s is refused; use the model's safetensors or GGUF, or set allow_pickle: true "+
|
||||||
|
"on the model if it was checked and has no other form", ErrPickle, redact(name))
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package weights
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPickleFormatsAreKnownByTheirNames(t *testing.T) {
|
||||||
|
for name, want := range map[string]bool{
|
||||||
|
"pytorch_model.bin": true, "model.ckpt": true, "x.PT": true, "w.pth": true, "a.pkl": true,
|
||||||
|
"https://h/r/resolve/main/model.ckpt?download=true": true,
|
||||||
|
"hf:o/r/model.bin?download=true": true, "hf:o/r/model.ckpt#x": true, "m.pt2": true,
|
||||||
|
"model.safetensors": false, "m-Q4_K_M.gguf": false, "Qwen/Qwen2.5-7B-Instruct": false,
|
||||||
|
"config.json": false, "": false,
|
||||||
|
} {
|
||||||
|
if got := Pickled(name); got != want {
|
||||||
|
t.Errorf("Pickled(%q) = %v, want %v", name, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPickleFileIsRefusedBeforeItIsFetched(t *testing.T) {
|
||||||
|
var hits atomic.Int64
|
||||||
|
srv := serveBytes(t, []byte("not really a pickle"), &hits)
|
||||||
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
|
|
||||||
|
_, err := f.Ensure(t.Context(), srv.URL+"/model.ckpt", false)
|
||||||
|
if !errors.Is(err, ErrPickle) {
|
||||||
|
t.Fatalf("a .ckpt was not refused: %v", err)
|
||||||
|
}
|
||||||
|
if hits.Load() != 0 {
|
||||||
|
t.Errorf("the refused file was fetched %d times", hits.Load())
|
||||||
|
}
|
||||||
|
if _, err := f.Ensure(t.Context(), srv.URL+"/model.ckpt", true); err != nil {
|
||||||
|
t.Errorf("allowed, the .ckpt was still refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASetWithAPickleFileIsRefusedWhole(t *testing.T) {
|
||||||
|
f, srv, hits := mirrorOf(t, map[string]string{
|
||||||
|
"pytorch_model.bin": "pickle",
|
||||||
|
"config.json": "{}",
|
||||||
|
})
|
||||||
|
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); !errors.Is(err, ErrPickle) {
|
||||||
|
t.Fatalf("a set holding pytorch_model.bin was not refused: %v", err)
|
||||||
|
}
|
||||||
|
if *hits != 0 {
|
||||||
|
t.Errorf("%d files of the refused set were fetched", *hits)
|
||||||
|
}
|
||||||
|
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", true); err != nil {
|
||||||
|
t.Errorf("allowed, the set was still refused: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestASetAlreadyHereIsLookedAtAgain(t *testing.T) {
|
||||||
|
// Fetched once for a model that allowed pickles, or before they were
|
||||||
|
// refused: another asking for it without leave is refused all the same.
|
||||||
|
f, srv, _ := mirrorOf(t, map[string]string{"pytorch_model.bin": "pickle", "config.json": "{}"})
|
||||||
|
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); !errors.Is(err, ErrPickle) {
|
||||||
|
t.Errorf("a set already on the node was not looked at: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
-16
@@ -5,12 +5,16 @@ import (
|
|||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsSet reports whether a source names a set of files rather than one.
|
// IsSet reports whether a source names a set of files rather than one.
|
||||||
@@ -46,7 +50,7 @@ type manifest struct {
|
|||||||
// The whole set or nothing. A directory with three of four shards in it loads
|
// The whole set or nothing. A directory with three of four shards in it loads
|
||||||
// as a broken model rather than failing to load, so a partial set is left
|
// as a broken model rather than failing to load, so a partial set is left
|
||||||
// unfinished under its temporary name and the next attempt starts again.
|
// unfinished under its temporary name and the next attempt starts again.
|
||||||
func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error) {
|
func (f *Fetcher) ensureSet(ctx context.Context, source string, allowPickle bool) (string, error) {
|
||||||
dir, err := f.cachePath(strings.TrimSuffix(source, "/"))
|
dir, err := f.cachePath(strings.TrimSuffix(source, "/"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
@@ -56,6 +60,16 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error)
|
|||||||
// exactly the failure the marker exists to prevent.
|
// exactly the failure the marker exists to prevent.
|
||||||
done := filepath.Join(dir, ".complete")
|
done := filepath.Join(dir, ".complete")
|
||||||
if _, err := os.Stat(done); err == nil {
|
if _, err := os.Stat(done); err == nil {
|
||||||
|
// Already here, maybe fetched for a model that allowed pickles, or
|
||||||
|
// before they were refused: looked at again, not trusted.
|
||||||
|
if !allowPickle {
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
for _, e := range entries {
|
||||||
|
if Pickled(e.Name()) {
|
||||||
|
return "", refusePickle(source + e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return dir, nil
|
return dir, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,10 +80,25 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error)
|
|||||||
if len(m.Files) == 0 {
|
if len(m.Files) == 0 {
|
||||||
return "", fmt.Errorf("%s holds no files", source)
|
return "", fmt.Errorf("%s holds no files", source)
|
||||||
}
|
}
|
||||||
|
// Refused whole rather than skipped: an engine given the directory
|
||||||
|
// loads the pickle when there is no safetensors beside it.
|
||||||
|
if !allowPickle {
|
||||||
|
for _, file := range m.Files {
|
||||||
|
if Pickled(file.Name) {
|
||||||
|
return "", refusePickle(source + file.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
// One number for the whole set, as the shards waiting on it see it: the
|
||||||
|
// file being fetched was shown alone, a 46 GB part of 188.
|
||||||
|
var setTotal, before int64
|
||||||
|
for _, file := range m.Files {
|
||||||
|
setTotal += file.Bytes
|
||||||
|
}
|
||||||
for _, file := range m.Files {
|
for _, file := range m.Files {
|
||||||
// Rejected rather than joined: a name with a separator in it would
|
// Rejected rather than joined: a name with a separator in it would
|
||||||
// write outside the directory the mirror said it was in, and the
|
// write outside the directory the mirror said it was in, and the
|
||||||
@@ -79,34 +108,24 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error)
|
|||||||
}
|
}
|
||||||
target := filepath.Join(dir, file.Name)
|
target := filepath.Join(dir, file.Name)
|
||||||
if st, err := os.Stat(target); err == nil && st.Size() == file.Bytes {
|
if st, err := os.Stat(target); err == nil && st.Size() == file.Bytes {
|
||||||
|
before += file.Bytes
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// Reported under the set's own address, because that is what the
|
// Reported under the set's own address, because that is what the
|
||||||
// shards are waiting on. Reusing fetch rather than writing a second
|
// shards are waiting on. Reusing fetch rather than writing a second
|
||||||
// downloader keeps resume, the .part rename and the progress callback
|
// downloader keeps resume, the .part rename and the progress callback
|
||||||
// in one place.
|
// in one place.
|
||||||
if _, err := f.fetchAs(ctx, source+file.Name, source, target); err != nil {
|
if _, err := f.fetchAs(ctx, source+file.Name, source, target, setShare{before: before, total: setTotal}); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
before += file.Bytes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Each file against the checksum the mirror took when it fetched it,
|
// Each file against the checksum the mirror took when it fetched it,
|
||||||
// before the set is called complete: a shard damaged on the way loads as
|
// before the set is called complete: a shard damaged on the way loads as
|
||||||
// a model that answers nonsense, not as an error.
|
// a model that answers nonsense, not as an error.
|
||||||
for _, file := range m.Files {
|
if err := f.checkSet(ctx, source, dir, m); err != nil {
|
||||||
if file.SHA256 == "" {
|
return "", err
|
||||||
continue
|
|
||||||
}
|
|
||||||
target := filepath.Join(dir, file.Name)
|
|
||||||
got, err := fileSHA256(target)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
if !strings.EqualFold(got, file.SHA256) {
|
|
||||||
_ = os.Remove(target)
|
|
||||||
return "", fmt.Errorf("%s%s: sha256 %s, the mirror has %s; removed, to be fetched again",
|
|
||||||
source, file.Name, got, file.SHA256)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.WriteFile(done, []byte(source+"\n"), 0o644); err != nil {
|
if err := os.WriteFile(done, []byte(source+"\n"), 0o644); err != nil {
|
||||||
@@ -168,3 +187,101 @@ func fileSHA256(path string) (string, error) {
|
|||||||
}
|
}
|
||||||
return hex.EncodeToString(h.Sum(nil)), nil
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkSetAtOnce is how many of a set's files are checked at once: a set of
|
||||||
|
// six 50 GB files took eight minutes on one core, with nothing said.
|
||||||
|
const checkSetAtOnce = 4
|
||||||
|
|
||||||
|
// checkSet checks each file of a set against the checksum the mirror took
|
||||||
|
// when it fetched it, several at once, saying how far it has got.
|
||||||
|
func (f *Fetcher) checkSet(ctx context.Context, source, dir string, m manifest) error {
|
||||||
|
var total int64
|
||||||
|
for _, file := range m.Files {
|
||||||
|
if file.SHA256 != "" {
|
||||||
|
total += file.Bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if total == 0 {
|
||||||
|
return nil // nothing the mirror took a checksum of
|
||||||
|
}
|
||||||
|
var done atomic.Int64
|
||||||
|
stop := make(chan struct{})
|
||||||
|
defer close(stop)
|
||||||
|
go func() {
|
||||||
|
t := time.NewTicker(2 * time.Second)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
f.report(Progress{Source: source, BytesDone: done.Load(), BytesTotal: total, Phase: PhaseChecking})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
f.report(Progress{Source: source, BytesTotal: total, Phase: PhaseChecking})
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
first error
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
slots := make(chan struct{}, checkSetAtOnce)
|
||||||
|
for _, file := range m.Files {
|
||||||
|
if file.SHA256 == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
wg.Add(1)
|
||||||
|
slots <- struct{}{}
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
defer func() { <-slots }()
|
||||||
|
target := filepath.Join(dir, file.Name)
|
||||||
|
got, err := countedSHA256(ctx, target, &done)
|
||||||
|
if err == nil && !strings.EqualFold(got, file.SHA256) {
|
||||||
|
_ = os.Remove(target)
|
||||||
|
err = fmt.Errorf("%s%s: sha256 %s, the mirror has %s; removed, to be fetched again",
|
||||||
|
source, file.Name, got, file.SHA256)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
mu.Lock()
|
||||||
|
if first == nil {
|
||||||
|
first = err
|
||||||
|
}
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if first == nil {
|
||||||
|
f.report(Progress{Source: source, BytesDone: total, BytesTotal: total, Phase: PhaseChecking})
|
||||||
|
}
|
||||||
|
return first
|
||||||
|
}
|
||||||
|
|
||||||
|
// countedSHA256 is a file's checksum, adding what it has read to done.
|
||||||
|
func countedSHA256(ctx context.Context, path string, done *atomic.Int64) (string, error) {
|
||||||
|
file, err := os.Open(path) //nolint:gosec // a file this fetcher wrote
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
buf := make([]byte, 4<<20)
|
||||||
|
for {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
n, err := file.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
h.Write(buf[:n])
|
||||||
|
done.Add(int64(n))
|
||||||
|
}
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return hex.EncodeToString(h.Sum(nil)), nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,10 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// mirrorOf serves a manifest at "/m/v/" and the files beneath it.
|
// mirrorOf serves a manifest at "/m/v/" and the files beneath it.
|
||||||
@@ -53,7 +56,7 @@ func TestASetIsFetchedIntoOneDirectory(t *testing.T) {
|
|||||||
"config.json": "{}",
|
"config.json": "{}",
|
||||||
})
|
})
|
||||||
|
|
||||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -74,11 +77,11 @@ func TestASetIsFetchedIntoOneDirectory(t *testing.T) {
|
|||||||
func TestAFinishedSetIsNotFetchedAgain(t *testing.T) {
|
func TestAFinishedSetIsNotFetchedAgain(t *testing.T) {
|
||||||
f, srv, hits := mirrorOf(t, map[string]string{"a.gguf": "aaaa", "b.gguf": "bb"})
|
f, srv, hits := mirrorOf(t, map[string]string{"a.gguf": "aaaa", "b.gguf": "bb"})
|
||||||
|
|
||||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
after := *hits
|
after := *hits
|
||||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if *hits != after {
|
if *hits != after {
|
||||||
@@ -100,7 +103,7 @@ func TestAHalfFetchedSetIsNotTreatedAsFinished(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -117,7 +120,7 @@ func TestAFileAlreadyThereAtTheRightSizeIsNotRefetched(t *testing.T) {
|
|||||||
_ = os.MkdirAll(dir, 0o755)
|
_ = os.MkdirAll(dir, 0o755)
|
||||||
_ = os.WriteFile(filepath.Join(dir, "a.gguf"), []byte("aaaa"), 0o644)
|
_ = os.WriteFile(filepath.Join(dir, "a.gguf"), []byte("aaaa"), 0o644)
|
||||||
|
|
||||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
// Resuming a set means not paying for the shards that already landed.
|
// Resuming a set means not paying for the shards that already landed.
|
||||||
@@ -133,7 +136,7 @@ func TestAFilenameThatEscapesTheDirectoryIsRefused(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
f := &Fetcher{CacheDir: t.TempDir()}
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
|
|
||||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||||
// The mirror is a place an operator copies files into by hand, so what it
|
// The mirror is a place an operator copies files into by hand, so what it
|
||||||
// reports is not automatically safe to join onto a path.
|
// reports is not automatically safe to join onto a path.
|
||||||
if err == nil || !strings.Contains(err.Error(), "offered a file named") {
|
if err == nil || !strings.Contains(err.Error(), "offered a file named") {
|
||||||
@@ -148,7 +151,7 @@ func TestADirectoryListingIsNotAManifest(t *testing.T) {
|
|||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
f := &Fetcher{CacheDir: t.TempDir()}
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
|
|
||||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||||
// "invalid character '<'" sends somebody looking at the wrong thing.
|
// "invalid character '<'" sends somebody looking at the wrong thing.
|
||||||
if err == nil || !strings.Contains(err.Error(), "has to be a mirror version") {
|
if err == nil || !strings.Contains(err.Error(), "has to be a mirror version") {
|
||||||
t.Errorf("the reason should name the cause, got %v", err)
|
t.Errorf("the reason should name the cause, got %v", err)
|
||||||
@@ -182,12 +185,12 @@ func TestASetFileThatDoesNotMatchTheMirrorsChecksumIsRefused(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
f := &Fetcher{CacheDir: t.TempDir()}
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err == nil || !strings.Contains(err.Error(), "sha256") {
|
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err == nil || !strings.Contains(err.Error(), "sha256") {
|
||||||
t.Fatalf("a damaged shard was accepted: %v", err)
|
t.Fatalf("a damaged shard was accepted: %v", err)
|
||||||
}
|
}
|
||||||
served = good
|
served = good
|
||||||
// The size differs, so it is fetched again; this time it matches.
|
// The size differs, so it is fetched again; this time it matches.
|
||||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||||
t.Fatalf("the shard as mirrored: %v", err)
|
t.Fatalf("the shard as mirrored: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -198,9 +201,109 @@ func TestASetStillDownloadingOnTheMirrorSaysSo(t *testing.T) {
|
|||||||
}))
|
}))
|
||||||
t.Cleanup(srv.Close)
|
t.Cleanup(srv.Close)
|
||||||
f := &Fetcher{CacheDir: t.TempDir()}
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||||
if err == nil || !strings.Contains(err.Error(), "not complete on the mirror yet") ||
|
if err == nil || !strings.Contains(err.Error(), "not complete on the mirror yet") ||
|
||||||
!strings.Contains(err.Error(), "model-00002-of-00002") {
|
!strings.Contains(err.Error(), "model-00002-of-00002") {
|
||||||
t.Fatalf("got %v", err)
|
t.Fatalf("got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A set's download is one number climbing against the whole set, not each
|
||||||
|
// part's own: a 46 GB part of 188 was shown as the download.
|
||||||
|
func TestASetsProgressIsTheWholeSets(t *testing.T) {
|
||||||
|
f, srv, _ := mirrorOf(t, map[string]string{
|
||||||
|
"m-00001-of-00002.gguf": strings.Repeat("a", 600),
|
||||||
|
"m-00002-of-00002.gguf": strings.Repeat("b", 400),
|
||||||
|
})
|
||||||
|
var seen []Progress
|
||||||
|
f.OnProgress = func(p Progress) { seen = append(seen, p) }
|
||||||
|
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(seen) == 0 {
|
||||||
|
t.Fatal("no progress reported")
|
||||||
|
}
|
||||||
|
last := int64(-1)
|
||||||
|
for _, p := range seen {
|
||||||
|
if p.BytesTotal != 1000 {
|
||||||
|
t.Fatalf("reported against %d, not the set's 1000: %+v", p.BytesTotal, seen)
|
||||||
|
}
|
||||||
|
if p.BytesDone < last {
|
||||||
|
t.Fatalf("went back: %+v", seen)
|
||||||
|
}
|
||||||
|
last = p.BytesDone
|
||||||
|
}
|
||||||
|
if last != 1000 {
|
||||||
|
t.Errorf("ended at %d", last)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A set's files checked after they arrive say so, as far as they have got,
|
||||||
|
// rather than leaving the download at 100% for minutes.
|
||||||
|
func TestASetBeingCheckedSaysSo(t *testing.T) {
|
||||||
|
parts := map[string]string{"a.gguf": strings.Repeat("a", 300), "b.gguf": strings.Repeat("b", 200)}
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/") {
|
||||||
|
var files []string
|
||||||
|
for n, body := range parts {
|
||||||
|
sum := sha256.Sum256([]byte(body))
|
||||||
|
files = append(files, fmt.Sprintf(`{"name":%q,"bytes":%d,"sha256":%q}`, n, len(body), hex.EncodeToString(sum[:])))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, `{"model":"m","version":"v","files":[%s]}`, strings.Join(files, ","))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(parts[strings.TrimPrefix(r.URL.Path, "/m/v/")]))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
var mu sync.Mutex
|
||||||
|
var checking []Progress
|
||||||
|
f := &Fetcher{CacheDir: t.TempDir(), OnProgress: func(p Progress) {
|
||||||
|
if p.Phase == PhaseChecking {
|
||||||
|
mu.Lock()
|
||||||
|
checking = append(checking, p)
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(checking) < 2 || checking[0].BytesDone != 0 || checking[len(checking)-1].BytesDone != 500 ||
|
||||||
|
checking[len(checking)-1].BytesTotal != 500 {
|
||||||
|
t.Errorf("%+v", checking)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four replicas on one node's four cards start together and want the same
|
||||||
|
// set: it is fetched once, and every one of them gets it. Each fetching it
|
||||||
|
// alone wrote the same .part files over one another, and three failed.
|
||||||
|
func TestASetWantedByFourReplicasAtOnceIsFetchedOnce(t *testing.T) {
|
||||||
|
body := strings.Repeat("a", 1<<20)
|
||||||
|
var hits atomic.Int64
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if strings.HasSuffix(r.URL.Path, "/") {
|
||||||
|
fmt.Fprintf(w, `{"model":"m","version":"v","files":[{"name":"a.safetensors","bytes":%d},{"name":"b.safetensors","bytes":%d}]}`,
|
||||||
|
len(body), len(body))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
hits.Add(1)
|
||||||
|
time.Sleep(100 * time.Millisecond) // long enough for all four to ask
|
||||||
|
_, _ = w.Write([]byte(body))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errs := make([]error, 4)
|
||||||
|
for i := range errs {
|
||||||
|
wg.Go(func() { _, errs[i] = f.Ensure(t.Context(), srv.URL+"/m/v/", false) })
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
for i, err := range errs {
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("replica %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n := hits.Load(); n != 2 {
|
||||||
|
t.Errorf("%d downloads of a set of two files", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ func TestAFetchSaysInItsTraceWhetherItDownloadedAndHowMuch(t *testing.T) {
|
|||||||
f := &Fetcher{CacheDir: t.TempDir()}
|
f := &Fetcher{CacheDir: t.TempDir()}
|
||||||
src := strings.Replace(srv.URL, "http://", "http://user:secret@", 1) + "/m/model.gguf?token=hunter2"
|
src := strings.Replace(srv.URL, "http://", "http://user:secret@", 1) + "/m/model.gguf?token=hunter2"
|
||||||
|
|
||||||
if _, err := f.Ensure(t.Context(), src); err != nil {
|
if _, err := f.Ensure(t.Context(), src, false); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
fetch := rec.One(t, [16]byte{}, "fetch weights")
|
fetch := rec.One(t, [16]byte{}, "fetch weights")
|
||||||
@@ -30,7 +30,7 @@ func TestAFetchSaysInItsTraceWhetherItDownloadedAndHowMuch(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The second start on this node finds it here.
|
// The second start on this node finds it here.
|
||||||
if _, err := f.Ensure(t.Context(), src); err != nil {
|
if _, err := f.Ensure(t.Context(), src, false); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
again := rec.Named("fetch weights")
|
again := rec.Named("fetch weights")
|
||||||
|
|||||||
@@ -112,8 +112,9 @@ func (w *statusWriter) Flush() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// notChanges are POSTs that change nothing and carry text that must not be
|
// notChanges are POSTs that change nothing and carry text that must not be
|
||||||
// kept: asking a judge about a sentence.
|
// kept: asking a judge about a sentence. And a standby's report of what its
|
||||||
var notChanges = []string{"/api/v1/guard/judges/ask"}
|
// router sent where, every two seconds: activity, not an operator's change.
|
||||||
|
var notChanges = []string{"/api/v1/guard/judges/ask", "/api/v1/routing/activity"}
|
||||||
|
|
||||||
// API records every change to the admin API: a request that is not a GET or
|
// API records every change to the admin API: a request that is not a GET or
|
||||||
// HEAD, under /api/. actor says who made it.
|
// HEAD, under /api/. actor says who made it.
|
||||||
|
|||||||
+35
-17
@@ -15,6 +15,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||||
|
"golang.org/x/sync/singleflight"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Principal is who a request is, once authenticated.
|
// Principal is who a request is, once authenticated.
|
||||||
@@ -118,6 +119,12 @@ type Authenticator struct {
|
|||||||
// lookups rations the database reads of keys not in the cache, per
|
// lookups rations the database reads of keys not in the cache, per
|
||||||
// client address.
|
// client address.
|
||||||
lookups *buckets
|
lookups *buckets
|
||||||
|
|
||||||
|
// resolving shares one database read between the requests that miss the
|
||||||
|
// cache for the same key at once: when a busy key's entry expired, every
|
||||||
|
// request in flight missed together, each took from the client's ration,
|
||||||
|
// and a valid key was told to slow down.
|
||||||
|
resolving singleflight.Group
|
||||||
}
|
}
|
||||||
|
|
||||||
type entry struct {
|
type entry struct {
|
||||||
@@ -166,26 +173,37 @@ func (a *Authenticator) AuthenticateFrom(ctx context.Context, presented, client
|
|||||||
return e.principal, e.err
|
return e.principal, e.err
|
||||||
}
|
}
|
||||||
|
|
||||||
if client != "" && !a.lookups.allow(client, a.now()) {
|
// One read for every request missing this key at once, rationed once.
|
||||||
return Principal{}, ErrThrottled
|
v, err, _ := a.resolving.Do(id, func() (any, error) {
|
||||||
}
|
if e, ok := a.lookup(id); ok { // filled while this waited its turn
|
||||||
p, err := a.resolve(ctx, hash)
|
return e, nil
|
||||||
if err != nil && !isRejection(err) {
|
}
|
||||||
// A database problem is not an authentication answer, so it must not be
|
if client != "" && !a.lookups.allow(client, a.now()) {
|
||||||
// cached as one. Failing the request is right — the alternative is
|
return nil, ErrThrottled
|
||||||
// serving traffic whose authorization is unknown.
|
}
|
||||||
|
p, err := a.resolve(ctx, hash)
|
||||||
|
if err != nil && !isRejection(err) {
|
||||||
|
// A database problem is not an authentication answer, so it must
|
||||||
|
// not be cached as one. Failing the request is right — the
|
||||||
|
// alternative is serving traffic whose authorization is unknown.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ttl := a.cfg.TTL
|
||||||
|
if err != nil {
|
||||||
|
ttl = a.cfg.NegativeTTL
|
||||||
|
}
|
||||||
|
e := entry{principal: p, err: err, expires: a.now().Add(ttl)}
|
||||||
|
a.store_(id, e)
|
||||||
|
return e, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
return Principal{}, err
|
return Principal{}, err
|
||||||
}
|
}
|
||||||
|
e := v.(entry)
|
||||||
ttl := a.cfg.TTL
|
if e.err == nil {
|
||||||
if err != nil {
|
a.markUsed(e.principal.KeyID)
|
||||||
ttl = a.cfg.NegativeTTL
|
|
||||||
}
|
}
|
||||||
a.store_(id, entry{principal: p, err: err, expires: a.now().Add(ttl)})
|
return e.principal, e.err
|
||||||
if err == nil {
|
|
||||||
a.markUsed(p.KeyID)
|
|
||||||
}
|
|
||||||
return p, err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Authenticator) resolve(ctx context.Context, hash []byte) (Principal, error) {
|
func (a *Authenticator) resolve(ctx context.Context, hash []byte) (Principal, error) {
|
||||||
|
|||||||
@@ -478,3 +478,41 @@ func TestTheCacheDoesNotGrowWithoutBound(t *testing.T) {
|
|||||||
t.Errorf("%d entries", n)
|
t.Errorf("%d entries", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A busy key whose entry has expired: every request in flight misses at once,
|
||||||
|
// and they share one read and one of the address's allowance. Each taking
|
||||||
|
// its own told a valid key to slow down, every thirty seconds, under load.
|
||||||
|
func TestABusyKeyMissingTheCacheTogetherIsReadOnceAndNotThrottled(t *testing.T) {
|
||||||
|
s, secret, _ := fixture(t)
|
||||||
|
c := &slowStore{countingStore: countingStore{Store: s}, delay: 50 * time.Millisecond}
|
||||||
|
a := New(c, DefaultConfig())
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
var throttled atomic.Int64
|
||||||
|
for range 200 {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if _, err := a.AuthenticateFrom(context.Background(), secret, "203.0.113.9"); errors.Is(err, ErrThrottled) {
|
||||||
|
throttled.Add(1)
|
||||||
|
} else if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if throttled.Load() != 0 || c.lookups.Load() != 1 {
|
||||||
|
t.Errorf("%d throttled, %d database reads", throttled.Load(), c.lookups.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// slowStore is a countingStore whose reads take a while, so the requests
|
||||||
|
// missing together are still waiting when the first one asks.
|
||||||
|
type slowStore struct {
|
||||||
|
countingStore
|
||||||
|
delay time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *slowStore) KeyByHash(ctx context.Context, hash []byte) (store.Key, error) {
|
||||||
|
time.Sleep(s.delay)
|
||||||
|
return s.countingStore.KeyByHash(ctx, hash)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
+23
-3
@@ -186,6 +186,7 @@ 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),
|
newTrainCmd(client, &asJSON),
|
||||||
newConfigCmd(client, &asJSON),
|
newConfigCmd(client, &asJSON),
|
||||||
@@ -320,6 +321,8 @@ func locationColumn(n adminapi.NodeView) string {
|
|||||||
// actually served, never what is merely catalogued.
|
// actually served, never what is merely catalogued.
|
||||||
func modelStatus(m adminapi.ModelView) string {
|
func modelStatus(m adminapi.ModelView) string {
|
||||||
switch {
|
switch {
|
||||||
|
case m.Stopped:
|
||||||
|
return "stopped (llmgw ctl model start)"
|
||||||
case m.Parked:
|
case m.Parked:
|
||||||
return "parked"
|
return "parked"
|
||||||
case m.External != "":
|
case m.External != "":
|
||||||
@@ -415,7 +418,7 @@ func newModelsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(w, "%s\t%s\t%d/%d\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
fmt.Fprintf(w, "%s\t%s\t%d/%d\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||||
name, serving, m.Ready, m.Min, modelStatus(m), m.Class, reserves(m),
|
name, serving, m.Ready, m.Min, modelStatus(m), m.Class, reserves(m),
|
||||||
join(m.Aliases), join(m.Profiles), join(m.Fallbacks))
|
join(m.Aliases), join(profilesWithCounts(m)), join(m.Fallbacks))
|
||||||
for _, x := range m.Warnings {
|
for _, x := range m.Warnings {
|
||||||
warnings = append(warnings, m.Name+": "+x)
|
warnings = append(warnings, m.Name+": "+x)
|
||||||
}
|
}
|
||||||
@@ -496,8 +499,12 @@ func newReplicasCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
|||||||
if s.Fetch == nil {
|
if s.Fetch == nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fmt.Fprintf(w, " %s: fetching %s\t\t\t\t\t\t\t\t\n",
|
what := "fetching"
|
||||||
s.Node, fetchColumn(s.Fetch))
|
if s.Fetch.Phase != "" {
|
||||||
|
what = s.Fetch.Phase
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, " %s: %s %s\t\t\t\t\t\t\t\t\n",
|
||||||
|
s.Node, what, fetchColumn(s.Fetch))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_ = w.Flush()
|
_ = w.Flush()
|
||||||
@@ -689,3 +696,16 @@ func reserves(m adminapi.ModelView) string {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// profilesWithCounts are a model's profiles, each with its replicas when
|
||||||
|
// they are counted per profile: gpu=7, cpu=4.
|
||||||
|
func profilesWithCounts(m adminapi.ModelView) []string {
|
||||||
|
if len(m.ProfileReplicas) == 0 {
|
||||||
|
return m.Profiles
|
||||||
|
}
|
||||||
|
out := make([]string, len(m.Profiles))
|
||||||
|
for i, p := range m.Profiles {
|
||||||
|
out[i] = fmt.Sprintf("%s=%d", p, m.ProfileReplicas[p])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+98
-19
@@ -11,11 +11,13 @@ import (
|
|||||||
"runtime"
|
"runtime"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/release"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
||||||
@@ -244,6 +246,7 @@ func upgradeNodes(ctx context.Context, out io.Writer, client func() *ctlClient,
|
|||||||
}
|
}
|
||||||
slices.SortFunc(nodes, func(a, b adminapi.NodeView) int { return strings.Compare(a.ID, b.ID) })
|
slices.SortFunc(nodes, func(a, b adminapi.NodeView) int { return strings.Compare(a.ID, b.ID) })
|
||||||
var todo []adminapi.NodeView
|
var todo []adminapi.NodeView
|
||||||
|
var away []string
|
||||||
skipped := 0
|
skipped := 0
|
||||||
for _, n := range nodes {
|
for _, n := range nodes {
|
||||||
if len(only) > 0 && !slices.Contains(only, n.ID) {
|
if len(only) > 0 && !slices.Contains(only, n.ID) {
|
||||||
@@ -253,8 +256,17 @@ func upgradeNodes(ctx context.Context, out io.Writer, client func() *ctlClient,
|
|||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// A node that is not connected cannot be told, and one tried first
|
||||||
|
// stopped every run at it: passed over, and named at the end.
|
||||||
|
if n.State == "lost" || n.State == "unreachable" {
|
||||||
|
away = append(away, n.ID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
todo = append(todo, n)
|
todo = append(todo, n)
|
||||||
}
|
}
|
||||||
|
if len(away) > 0 {
|
||||||
|
fmt.Fprintf(out, "Passed over, not connected: %s\n", strings.Join(away, ", "))
|
||||||
|
}
|
||||||
parallel = max(parallel, 1)
|
parallel = max(parallel, 1)
|
||||||
start, size, done := time.Now(), 1, 0
|
start, size, done := time.Now(), 1, 0
|
||||||
for len(todo) > 0 {
|
for len(todo) > 0 {
|
||||||
@@ -296,7 +308,10 @@ func upgradeNodes(ctx context.Context, out io.Writer, client func() *ctlClient,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// upgradeWave tells every node in the wave at once, and waits until each is
|
// upgradeWave tells every node in the wave at once, and waits until each is
|
||||||
// back on v, ready, with as many ready replicas as it had.
|
// back on v and serving what it served.
|
||||||
|
//
|
||||||
|
// Told together, not one after another: each answer can take minutes, and a
|
||||||
|
// wave of twenty waited for them in turn with nothing on the screen.
|
||||||
func upgradeWave(ctx context.Context, out io.Writer, client func() *ctlClient, v string,
|
func upgradeWave(ctx context.Context, out io.Writer, client func() *ctlClient, v string,
|
||||||
wave []adminapi.NodeView, perNode time.Duration,
|
wave []adminapi.NodeView, perNode time.Duration,
|
||||||
) error {
|
) error {
|
||||||
@@ -308,35 +323,61 @@ func upgradeWave(ctx context.Context, out io.Writer, client func() *ctlClient, v
|
|||||||
}
|
}
|
||||||
fmt.Fprintf(out, "== %d node(s): %s\n", len(wave), abbreviate(ids, 8))
|
fmt.Fprintf(out, "== %d node(s): %s\n", len(wave), abbreviate(ids, 8))
|
||||||
body, _ := json.Marshal(adminapi.UpgradeRequest{Version: v})
|
body, _ := json.Marshal(adminapi.UpgradeRequest{Version: v})
|
||||||
var failed []string
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
failed []string
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
for _, n := range wave {
|
for _, n := range wave {
|
||||||
// The control plane waits for the agent to fetch and check the
|
wg.Add(1)
|
||||||
// release, up to five minutes; fifteen seconds gave up on a node
|
go func() {
|
||||||
// that was upgrading.
|
defer wg.Done()
|
||||||
if err := client().patient(6*time.Minute).post(ctx, "/api/v1/nodes/"+n.ID+"/upgrade", body, nil); err != nil {
|
// The control plane waits for the agent's answer, or for it to
|
||||||
failed = append(failed, fmt.Sprintf("%s: %v", n.ID, err))
|
// come back on v, longer than the agent gives its download.
|
||||||
}
|
err := client().patient(agentsvc.UpgradeWait+time.Minute).post(ctx, "/api/v1/nodes/"+n.ID+"/upgrade", body, nil)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
failed = append(failed, fmt.Sprintf("%s: %v", n.ID, err))
|
||||||
|
fmt.Fprintf(out, " %s: not upgraded: %v\n", n.ID, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, " %s: on %s, coming back\n", n.ID, v)
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
if len(failed) > 0 {
|
wg.Wait()
|
||||||
return fmt.Errorf("not told to upgrade:\n %s\n`llmgw ctl logs NODE` says more", strings.Join(failed, "\n "))
|
waiting := slices.DeleteFunc(slices.Clone(ids), func(id string) bool {
|
||||||
}
|
return slices.ContainsFunc(failed, func(f string) bool { return strings.HasPrefix(f, id+": ") })
|
||||||
waiting := slices.Clone(ids)
|
})
|
||||||
deadline := time.Now().Add(perNode)
|
deadline := time.Now().Add(perNode)
|
||||||
for {
|
backAt := map[string]time.Time{}
|
||||||
|
said := time.Now()
|
||||||
|
for len(waiting) > 0 {
|
||||||
var now []adminapi.NodeView
|
var now []adminapi.NodeView
|
||||||
_ = client().get(ctx, "/api/v1/nodes", &now)
|
_ = client().get(ctx, "/api/v1/nodes", &now)
|
||||||
after := readyReplicasByNode(ctx, client)
|
var reps []adminapi.ReplicaView
|
||||||
|
_ = client().get(ctx, "/api/v1/replicas", &reps)
|
||||||
|
after, starting := replicasByNode(reps)
|
||||||
state := map[string]adminapi.NodeView{}
|
state := map[string]adminapi.NodeView{}
|
||||||
for _, x := range now {
|
for _, x := range now {
|
||||||
state[x.ID] = x
|
state[x.ID] = x
|
||||||
}
|
}
|
||||||
waiting = slices.DeleteFunc(waiting, func(id string) bool {
|
waiting = slices.DeleteFunc(waiting, func(id string) bool {
|
||||||
cur := state[id]
|
cur := state[id]
|
||||||
return cur.AgentVersion == v && cur.State == "ready" && after[id] >= before[id]
|
// Connected and on v: ready, or cordoned or draining as it
|
||||||
|
// was, which an upgrade does not undo.
|
||||||
|
if cur.AgentVersion != v || (cur.State != "ready" && cur.State != "cordoned" && cur.State != "draining") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, ok := backAt[id]; !ok {
|
||||||
|
backAt[id] = time.Now()
|
||||||
|
}
|
||||||
|
// Serving what it served; or, a while on, nothing on it still
|
||||||
|
// starting: a model that went idle meanwhile is not one lost.
|
||||||
|
return after[id] >= before[id] || (time.Since(backAt[id]) > 90*time.Second && starting[id] == 0)
|
||||||
})
|
})
|
||||||
if len(waiting) == 0 {
|
if len(waiting) == 0 {
|
||||||
fmt.Fprintf(out, " done in %s\n", time.Since(start).Round(time.Second))
|
break
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
if time.Now().After(deadline) {
|
if time.Now().After(deadline) {
|
||||||
var why []string
|
var why []string
|
||||||
@@ -345,11 +386,49 @@ func upgradeWave(ctx context.Context, out io.Writer, client func() *ctlClient, v
|
|||||||
why = append(why, fmt.Sprintf("%s (version %q, state %q, replicas %d/%d)",
|
why = append(why, fmt.Sprintf("%s (version %q, state %q, replicas %d/%d)",
|
||||||
id, cur.AgentVersion, cur.State, after[id], before[id]))
|
id, cur.AgentVersion, cur.State, after[id], before[id]))
|
||||||
}
|
}
|
||||||
return fmt.Errorf("not back within %s:\n %s\n`llmgw ctl logs NODE` says why, and each keeps its "+
|
failed = append(failed, fmt.Sprintf("not back within %s: %s", perNode, strings.Join(why, ", ")))
|
||||||
"previous binary beside the new one", perNode, strings.Join(why, "\n "))
|
break
|
||||||
|
}
|
||||||
|
if time.Since(said) >= 30*time.Second {
|
||||||
|
var w []string
|
||||||
|
for _, id := range waiting {
|
||||||
|
cur := state[id]
|
||||||
|
w = append(w, fmt.Sprintf("%s (%s, %s, replicas %d/%d)", id, orUnknown(cur.AgentVersion), cur.State,
|
||||||
|
after[id], before[id]))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, " waiting for %s\n", abbreviate(w, 8))
|
||||||
|
said = time.Now()
|
||||||
}
|
}
|
||||||
time.Sleep(2 * time.Second)
|
time.Sleep(2 * time.Second)
|
||||||
}
|
}
|
||||||
|
if len(failed) > 0 {
|
||||||
|
return fmt.Errorf("not upgraded:\n %s\n`llmgw ctl logs NODE` says why; each keeps its previous binary beside the new one",
|
||||||
|
strings.Join(failed, "\n "))
|
||||||
|
}
|
||||||
|
fmt.Fprintf(out, " done in %s\n", time.Since(start).Round(time.Second))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// replicasByNode counts each node's ready replicas, and those still placing
|
||||||
|
// or starting.
|
||||||
|
func replicasByNode(reps []adminapi.ReplicaView) (ready, starting map[string]int) {
|
||||||
|
ready, starting = map[string]int{}, map[string]int{}
|
||||||
|
for _, r := range reps {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, sh := range r.Shards {
|
||||||
|
if seen[sh.Node] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[sh.Node] = true
|
||||||
|
switch r.State {
|
||||||
|
case "ready":
|
||||||
|
ready[sh.Node]++
|
||||||
|
case "placing", "starting":
|
||||||
|
starting[sh.Node]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ready, starting
|
||||||
}
|
}
|
||||||
|
|
||||||
// abbreviate lists up to n names, and says how many more.
|
// abbreviate lists up to n names, and says how many more.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -21,6 +22,8 @@ type fakeFleet struct {
|
|||||||
versions map[string]string
|
versions map[string]string
|
||||||
stuck map[string]bool
|
stuck map[string]bool
|
||||||
told []string
|
told []string
|
||||||
|
// states are nodes' states other than ready.
|
||||||
|
states map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFleet) server(t *testing.T) *httptest.Server {
|
func (f *fakeFleet) server(t *testing.T) *httptest.Server {
|
||||||
@@ -31,7 +34,11 @@ func (f *fakeFleet) server(t *testing.T) *httptest.Server {
|
|||||||
case r.URL.Path == "/api/v1/nodes":
|
case r.URL.Path == "/api/v1/nodes":
|
||||||
var out []adminapi.NodeView
|
var out []adminapi.NodeView
|
||||||
for id, v := range f.versions {
|
for id, v := range f.versions {
|
||||||
out = append(out, adminapi.NodeView{ID: id, State: "ready", AgentVersion: v})
|
st := "ready"
|
||||||
|
if f.states[id] != "" {
|
||||||
|
st = f.states[id]
|
||||||
|
}
|
||||||
|
out = append(out, adminapi.NodeView{ID: id, State: st, AgentVersion: v})
|
||||||
}
|
}
|
||||||
_ = json.NewEncoder(w).Encode(out)
|
_ = json.NewEncoder(w).Encode(out)
|
||||||
case r.URL.Path == "/api/v1/replicas":
|
case r.URL.Path == "/api/v1/replicas":
|
||||||
@@ -201,3 +208,23 @@ func TestUpgradingAControlPlaneAloneSaysItsNodesWereNot(t *testing.T) {
|
|||||||
t.Errorf("said something with every node on the version: %s", out.String())
|
t.Errorf("said something with every node on the version: %s", out.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A cordoned node upgraded is done, as it was; a node not connected is passed
|
||||||
|
// over rather than stopping every node after it.
|
||||||
|
func TestAnUpgradeTakesACordonedNodeAndPassesOverOneAway(t *testing.T) {
|
||||||
|
f := &fakeFleet{versions: map[string]string{"a": "v1.0.0", "b": "v1.0.0", "c": "v1.0.0"},
|
||||||
|
states: map[string]string{"a": "lost", "b": "cordoned"}}
|
||||||
|
srv := f.server(t)
|
||||||
|
var out bytes.Buffer
|
||||||
|
err := upgradeNodes(t.Context(), &out, func() *ctlClient { return newCtlClient(srv.URL) }, "v1.0.1", nil, 5*time.Second, 2)
|
||||||
|
if slices.Contains(f.told, "a") || !slices.Contains(f.told, "b") || !slices.Contains(f.told, "c") {
|
||||||
|
t.Errorf("told %v", f.told)
|
||||||
|
}
|
||||||
|
if !strings.Contains(out.String(), "Passed over, not connected: a") {
|
||||||
|
t.Errorf("output:\n%s", out.String())
|
||||||
|
}
|
||||||
|
// The one away is named at the end, behind.
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "a (v1.0.0, lost)") {
|
||||||
|
t.Errorf("err %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
Loaded 100 of 465 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user