Compare commits

...
38 Commits
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 220110ed0d v0.16.2: changelog and README
Release / build (push) Successful in 5m43s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 11m11s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 08:32:34 +01:00
Ilker ManapandClaude Opus 5.5 a88624747e A set wanted by several replicas on one node is fetched once
CI / test (push) Failing after 7m23s
Single files were shared between the shards and replicas asking for them
at the same time; sets were not, and four replicas on one node's four cards
wrote the same .part files over one another until all but one failed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-02 08:29:25 +01:00
Ilker ManapandClaude Opus 5.5 540b518fde Rate limits counted across the control planes with router.active_active
CI / test (push) Successful in 9m30s
Each router's per-counter request and token counts, this minute's and the
last, ride the standby's two-second exchange with the leader. The leader
answers each standby with everyone else's sum, its own included, and puts
the standbys' sum in force on its own router; each limiter adds the
others' counts to its sliding window.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 17:30:08 +01:00
Ilker ManapandClaude Opus 5.5 95fb5eeb6e BACKLOG: O2 tried on the PoC, two routers carry 1.5x the leader alone
CI / test (push) Successful in 9m26s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 16:51:54 +01:00
Ilker ManapandClaude Opus 5.5 8b7eefef16 Standbys that answer the client API themselves, behind router.active_active
CI / test (push) Successful in 9m35s
A standby follows the leader's Ready replicas over the agent port, every
two seconds, and reports back the replicas its router used and the
on-demand models it was asked for. No engine keys travel: every control
plane derives them from the cluster secret. Budgets are raised to the
store's every 30 s. Unheard from the leader for a minute, a standby
passes /v1 on as before. Off by default.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-11-01 13:47:47 +01:00
Ilker ManapandClaude Opus 5.5 f664592948 BACKLOG: N8 half done, ctl model context takes Strata and the longest answer alone
CI / test (push) Successful in 9m21s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 09:51:26 +01:00
Ilker ManapandClaude Opus 5.5 31f440ab6e A served model's longest answer changed in place, and Strata's context
CI / test (push) Successful in 9m29s
max_output on POST /api/v1/models/{name}/context, --max-output on ctl model
context, and a field in the console's Context length… form. Alone it changes
only the catalog's limit, which the router holds max_tokens to, and starts no
replica again; with a context it is set with it, never past it.

Strata's context is now changed like the others', through its setup's
--context, which writes the engine's start script at each start. Neither
needs a model removed and added again any more.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 00:15:38 +01:00
Ilker ManapandClaude Opus 5.5 f4b34dbf11 FreeToken, an engine for models larger than the card
CI / test (push) Successful in 9m37s
FreeToken (FlashML-org/FreeToken) serves mixture-of-experts models several
times a card's size from one card, experts in RAM, from Hugging Face
safetensors checkpoints. Installed like vLLM, from PyPI by uv, on every node
it runs on: compute capability 8.0+, driver 580+, x86-64, and the CUDA 13
toolkit it compiles kernels with. Older cards are passed over: on cuda01's
Quadro RTX 6000 (7.5) 0.2.1 had no kernel image for the first prefill, and
in fp16 hit an illegal memory access on its first request.

ft serve gets the model, address, card and served name; the key goes in
FREETOKEN_API_KEY. Flags that read files or fetch are refused, as are
FREETOKEN_ variables naming paths or the agent's own settings. ctl model
context sets --max-seq-len-override.

Readiness now also waits for an engine whose /health answers 200 while its
body says "loading", as FreeToken's does from the moment its HTTP server is up.

FreeToken is followed release by release: a weekly workflow fails when PyPI
has a version newer than DefaultEngines installs (scripts/engine_releases.py).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:10:05 +01:00
Ilker ManapandClaude Opus 5.5 c866b3a8ee How much of each GPU works, beside whether anything does
CI / test (push) Failing after 6m42s
The busy figure is nvidia-smi's utilization.gpu: the share of time any
kernel ran, which a kernel on one SM fills. On cuda01 decode read 92% busy
while the SMs had work 69% of the time and issued on 18% of their cycles.

Busy is now said to be that, and beside it: each card's draw against its
power limit (efficiency table, fleet and node charts, the console's GPU
work chart), and where the profiling counters can be read, SMs active, SM
occupancy, tensor cores and DRAM (llmgw_gpu_*_active_percent), from NVML's
GPM on Hopper and later or a dcgm-exporter on the node
(discovery.dcgm_exporter). The GPU map colours by work by default and keeps
busy as a mode. A MIG instance is never published with its card's profile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:11:26 +01:00
Ilker ManapandClaude Opus 5.5 7bfe70c8df BACKLOG: the router profiled, the write per token removed, what is left
CI / test (push) Successful in 9m22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 15:50:15 +01:00
Ilker ManapandClaude Opus 5.5 5550fefaad Stream events already in hand go to the client in one write, not one each
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 15:30:19 +01:00
Ilker ManapandClaude Opus 5.5 808c4724d2 BACKLOG: O1 measured, the router's ceiling on the PoC
CI / test (push) Successful in 9m22s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 10:45:59 +01:00
Ilker ManapandClaude Opus 5.5 9ff49cad8e Requests missing the cache for the same key at once share one read and one allowance
CI / test (push) Successful in 9m51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-31 09:30:18 +01:00
Ilker ManapandClaude Opus 5.5 524f029603 External providers left out of admission's saturation; the scale-out section in the backlog
CI / test (push) Successful in 9m25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 19:13:15 +01:00
Ilker ManapandClaude Opus 5.5 be54f1617b A mirror download cut off carries on from where it got to, and is tried again
CI / test (push) Successful in 10m36s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 17:47:32 +01:00
Ilker ManapandClaude Opus 5.5 812eb8aac1 Regenerate the admin API document for the fetch phase
CI / test (push) Successful in 9m33s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 15:07:16 +01:00
Ilker ManapandClaude Opus 5.5 76746715ec A set's files checked four at once, the check said as it goes and not taken for a stuck download
CI / test (push) Failing after 6m51s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 14:58:44 +01:00
Ilker ManapandClaude Opus 5.5 d5dcf78836 A set's download reported as the whole set's, not the part being fetched
CI / test (push) Successful in 9m15s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 14:48:45 +01:00
Ilker ManapandClaude Opus 5.5 a5fefbdaaa v0.16.1: changelog and README
CI / test (push) Successful in 12m33s
Release / build (push) Successful in 6m32s
Release / notes (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 13:14:25 +01:00
Ilker ManapandClaude Opus 5.5 2eb96f4872 The plan says why Strata's DeepSeek does not run here
CI / test (push) Successful in 11m38s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 13:09:09 +01:00
Ilker ManapandClaude Opus 5.5 f41c96d30f Strata's DeepSeek not offered on cards its engine was not built for, and refused there by the agent
CI / test (push) Successful in 9m42s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 13:00:21 +01:00
Ilker ManapandClaude Opus 5.5 104b60fbf8 A repository's files read from every directory, not only its top
CI / test (push) Successful in 11m40s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 12:16:11 +01:00
Ilker ManapandClaude Opus 5.5 845de8eef4 The mirror page's Strata tab; Strata's ready-made packs served with Strata fetching them, strata-ple named
CI / test (push) Successful in 10m35s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 12:06:46 +01:00
Ilker ManapandClaude Opus 5.5 4ee9ba54ef v0.16.0: changelog and README
Release / build (push) Successful in 6m6s
Release / notes (push) Successful in 5s
CI / test (push) Successful in 11m14s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 11:21:32 +01:00
Ilker ManapandClaude Opus 5.5 12464ce0f6 Cluster upgrades: nodes told together with progress said, done when back on the version, the download kept past a broken stream
CI / test (push) Successful in 10m2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 11:14:02 +01:00
Ilker Manap d6f8f433f4 CHANGELOG: one list
CI / test (push) Successful in 9m18s
2026-10-30 10:39:30 +01:00
Ilker ManapandClaude Opus 5.5 7465c24830 Strata's families and sizes known: checked when a model is added, its memory reserved from them
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 10:17:42 +01:00
Ilker ManapandClaude Opus 5.5 91f668fe7e Strata named by hand is reported, its engine stopped with it, its memory required, Pascal allowed
CI / test (push) Successful in 9m28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 09:49:16 +01:00
Ilker ManapandClaude Opus 5.5 73091d7c97 A cached file checked as another is refused and left, not deleted for the next model
CI / test (push) Successful in 9m19s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 08:17:43 +01:00
Ilker ManapandClaude Opus 5.5 b8ed368e31 Weights from Hugging Face pinned to a commit and checked against the sha256 it published
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-30 07:33:15 +01:00
Ilker ManapandClaude Opus 5.5 29d7f54cfd A path escaped as it came reaches the leader through a standby; F5 in the backlog
CI / test (push) Successful in 9m30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 22:54:27 +01:00
Ilker ManapandClaude Opus 5.5 cacaa35c3f The gateway embeds the documents llmgw-kb sends as text
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 21:56:05 +01:00
Ilker ManapandClaude Opus 5.5 01ad8c9207 v0.15.3: changelog and README
CI / test (push) Successful in 11m44s
Release / build (push) Successful in 5m43s
Release / notes (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 20:54:53 +01:00
Ilker ManapandClaude Opus 5.5 59187cdae5 A model can be stopped and kept, or removed with its files from the mirror and the nodes
CI / test (push) Successful in 10m37s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 20:45:48 +01:00
Ilker ManapandClaude Opus 5.5 5726ceb84e BACKLOG: J4 done, tried on the PoC with the first mirror stopped
CI / test (push) Successful in 9m21s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 20:01:47 +01:00
Ilker ManapandClaude Opus 5.5 435ffe1171 A file removed outside the running mirror is no longer reported, and its removal reaches the peers
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 19:25:00 +01:00
Ilker ManapandClaude Opus 5.5 785e06ecbf A mirror with peers records the checksums of files that had none, so they are copied too
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 18:36:59 +01:00
Ilker ManapandClaude Opus 5.5 0033a530c7 mirror add-peer on a mirror machine of its own says what to run on a control plane
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-29 18:30:30 +01:00
163 changed files with 7907 additions and 361 deletions

No files matched your search

+20
View File
@@ -0,0 +1,20 @@
# Fails when an engine llmgw follows release by release (FreeToken) has
# published a version newer than the one DefaultEngines installs: the failed
# run is the notice to check the new version and move to it. See
# scripts/engine_releases.py.
name: Engine releases
on:
schedule:
# Mondays 06:00 UTC.
- cron: "0 6 * * 1"
workflow_dispatch:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Compare with PyPI
run: python3 scripts/engine_releases.py
+145 -18
View File
@@ -30,24 +30,23 @@ What is left, most needed first. The detail is in the item each names.
**Soon: the customer's experience, and HA.**
4. ~~**C24**~~: done 2026-10-21. The store's, catalog's and mirror's errors, percent signs, footer times and stale
forms are in the page's language. Left: the /docs documents, and a language kept per person (cookie only today).
5. **J4**: the model mirror on more than one machine. Code done 2026-10-29: mirrors kept in step (`mirror serve
--peer`, removals carried), `mirror.peers` and failover in the control plane, nodes given the file on a mirror that
answers, `mirror add-peer` and the setup's second mirror. Tried 2026-10-29 on two VMs:
- `add-peer` set the second up over ssh;
- a file came across in ~30 s, checksum matching;
- a version added on the second while the first was off reached the first when it came back;
- one removed on the first while the second was off was removed there when it came back, not copied back.
Left: the same with a control plane and a node, a model served while the first mirror is off. That part (failover
in the control plane, the node given the other mirror's file) is so far tried only in tests.
6. **F5**: large imports limited by the embedding engine's slots. (M)
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), then M5 (engines without the internet), M3, M4, M6.
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.
@@ -1133,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
queue and 0.04 s in the guard. The GPU sat at 18–50% and the store machine
at a third of its CPUs.
- An embedding model's profile with many slots by default (`--parallel`
- 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
longest chunk), or `ctl model serve` choosing that for a model with
`capabilities.embeddings`. With the default 512, one 516-token chunk failed.
- Embedding on the gateway's side of a document PUT, so the client sends
text only: today an embeddings answer is ~21 KB of JSON a chunk, and
the text and vector go up again (~11 KB).
- Done (2026-10-29): embedding on the gateway's side of a document PUT.
`llmgw-kb` sends text only to a gateway that says `server_embeds`; the
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
million chunks the vectors alone outgrow its 4 GB. halfvec (pgvector ≥
0.7) and a larger store machine.
@@ -1652,7 +1657,7 @@ their own (4 cores, 32 GB, two cores each) the same test refused 2.
- 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)
### 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
@@ -1683,6 +1688,8 @@ how to fix it by hand (`ctl model resources`, then `ctl restart`).
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`.
@@ -1820,7 +1827,14 @@ pickle files. `allow_pickle: true` on a model lets it through, on purpose. On th
PoC, `triposr` (`hf:stabilityai/TripoSR/model.ckpt`) is the one model that
needs it.
### M2. Every download pinned and checked against Hugging Face (M)
### 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
@@ -1855,6 +1869,119 @@ cluster, loopback), systemd hardening of the agent's engines
(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
- Admin API: `127.0.0.1:9090`, behind the token in
+207 -1
View File
@@ -19,12 +19,218 @@ is only released once its **exit criterion** is met, not when its code lands.
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves; v0.14.6: setup that explains every part and lets it be chosen, the gateway on all three HA machines; v0.14.7: the judge sized for its machine; v0.14.8: sign-in with Active Directory, several keys for one outside provider; v0.14.9: sign-in set up from the console, a node's console that shows the machine) |
| v0.15.0 | — | The company's MCP tools and VS Code through the gateway; guardrails that ask outside services and put redacted values back; answers to similar questions | released (v0.15.1: Strata, a 125B model of experts on one card; a second mirror kept in step; pickled weights refused; a served model's context changed; v0.15.2: a served model moved to more cards) |
| v0.15.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) |
| 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
APIs change between minors without a deprecation period.
## 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.
+2 -1
View File
@@ -41,7 +41,8 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves; v0.14.6: setup that explains every part and lets it be chosen, the gateway on all three HA machines; v0.14.7: the judge sized for its machine; v0.14.8: sign-in with Active Directory, several keys for one outside provider; v0.14.9: sign-in set up from the console, a node's console that shows the machine) |
| v0.15.0 | — | The company's MCP tools and VS Code through the gateway; guardrails that ask outside services and put redacted values back; answers to similar questions | released (v0.15.1: Strata, a 125B model of experts on one card; a second mirror kept in step; pickled weights refused; a served model's context changed; v0.15.2: a served model moved to more cards) |
| v0.15.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) |
| 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
+102 -14
View File
@@ -2219,12 +2219,27 @@ type GPUSensor struct {
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"`
// 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"`
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"`
LastXidUnix int64 `protobuf:"varint,28,opt,name=last_xid_unix,json=lastXidUnix,proto3" json:"last_xid_unix,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
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"`
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"`
// 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").
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() {
@@ -2453,6 +2468,48 @@ func (x *GPUSensor) GetLastXidUnix() int64 {
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.
type ShardLoad struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -3586,7 +3643,10 @@ type FetchProgress struct {
// 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
// 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
sizeCache protoimpl.SizeCache
}
@@ -3642,6 +3702,13 @@ func (x *FetchProgress) GetBytesTotal() int64 {
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
// plane retries, and a repeat must not produce a second process.
type StartShard struct {
@@ -3849,7 +3916,11 @@ type EngineProfile struct {
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"`
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
sizeCache protoimpl.SizeCache
}
@@ -3982,6 +4053,13 @@ func (x *EngineProfile) GetAllowPickle() bool {
return false
}
func (x *EngineProfile) GetModelSha256() string {
if x != nil {
return x.ModelSha256
}
return ""
}
// RemoveWeights deletes cached model files a node no longer needs.
//
// The paths are computed by the control plane from the catalog, but the agent
@@ -5356,7 +5434,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
" \x01(\x01R\tcpuPowerW\x12 \n" +
"\finlet_temp_c\x18\v \x01(\x01R\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" +
"\x05index\x18\x01 \x01(\x05R\x05index\x12\x15\n" +
"\x06temp_c\x18\x02 \x01(\x01R\x05tempC\x12\"\n" +
@@ -5388,7 +5467,14 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
"xid_errors\x18\x19 \x01(\x03R\txidErrors\x12\x19\n" +
"\blast_xid\x18\x1a \x01(\x05R\alastXid\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" +
"\bshard_id\x18\x01 \x01(\tR\ashardId\x12\x1b\n" +
"\tcpu_cores\x18\x02 \x01(\x01R\bcpuCores\x12(\n" +
@@ -5504,13 +5590,14 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
"\vSTATE_READY\x10\x04\x12\x12\n" +
"\x0eSTATE_DRAINING\x10\x05\x12\x11\n" +
"\rSTATE_STOPPED\x10\x06\x12\x10\n" +
"\fSTATE_FAILED\x10\a\"g\n" +
"\fSTATE_FAILED\x10\a\"}\n" +
"\rFetchProgress\x12\x16\n" +
"\x06source\x18\x01 \x01(\tR\x06source\x12\x1d\n" +
"\n" +
"bytes_done\x18\x02 \x01(\x03R\tbytesDone\x12\x1f\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" +
"StartShard\x12\x1d\n" +
"\n" +
@@ -5536,7 +5623,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
"profile_fp\x18\x0f \x01(\tR\tprofileFp\x12\x17\n" +
"\aapi_key\x18\x10 \x01(\tR\x06apiKey\x12\x1f\n" +
"\vcpu_threads\x18\x11 \x01(\x05R\n" +
"cpuThreads\"\xf0\x03\n" +
"cpuThreads\"\x93\x04\n" +
"\rEngineProfile\x12\x12\n" +
"\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" +
"\x06engine\x18\x02 \x01(\tR\x06engine\x12\x1b\n" +
@@ -5553,7 +5640,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
"\x11served_model_name\x18\v \x01(\tR\x0fservedModelName\x121\n" +
"\badapters\x18\f \x03(\v2\x15.llmgw.v1.LoraAdapterR\badapters\x12\x1b\n" +
"\tkv_events\x18\r \x01(\bR\bkvEvents\x12!\n" +
"\fallow_pickle\x18\x0e \x01(\bR\vallowPickle\x1a6\n" +
"\fallow_pickle\x18\x0e \x01(\bR\vallowPickle\x12!\n" +
"\fmodel_sha256\x18\x0f \x01(\tR\vmodelSha256\x1a6\n" +
"\bEnvEntry\x12\x10\n" +
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"%\n" +
+22
View File
@@ -448,6 +448,21 @@ message GPUSensor {
int32 last_xid = 26;
string last_xid_text = 27;
int64 last_xid_unix = 28;
// How much of the chip the work on it uses, which utilization_percent
// does not say: that is the share of time any kernel ran, and a kernel on
// one SM of a hundred counts in full. has_profile says these are present;
// -1 is not reported. Read from NVML's GPM on Hopper and later, or from a
// dcgm-exporter on the node; profile_source says which ("gpm", "dcgm").
bool has_profile = 29;
// sm_active_percent is the share of time the SMs had work, averaged over
// all of them; sm_occupancy_percent how full those SMs were with warps.
double sm_active_percent = 30;
double sm_occupancy_percent = 31;
// tensor_active_percent is the tensor cores; dram_active_percent the
// memory interface, which decode is bound by.
double tensor_active_percent = 32;
double dram_active_percent = 33;
string profile_source = 34;
}
// ShardLoad is one engine process group's use.
@@ -705,6 +720,9 @@ message FetchProgress {
// is a real answer: a percentage would then be invented, and an invented
// percentage that stops moving is worse than a byte count that keeps rising.
int64 bytes_total = 3;
// phase is what the bytes are: empty while they arrive, "checking" while the
// node checks a set's files against the mirror's checksums.
string phase = 4;
}
// StartShard launches one shard. It is idempotent by shard_id: the control
@@ -767,6 +785,10 @@ message EngineProfile {
// 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.
+2
View File
@@ -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.Retry, "retry", false, "read again files skipped before")
f.IntVar(&o.Workers, "workers", 4, "files read and sent at once")
f.BoolVar(&o.EmbedHere, "embed-here", false,
"embed on this machine and send the vectors, even to a gateway that embeds text it is sent")
f.StringVar(&ocr, "ocr-languages", "tur+eng", "Tesseract's languages for images and scanned pages")
f.Int64Var(&maxMiB, "max-size", 256, "skip files larger than this many MiB")
f.BoolVarP(&verbose, "verbose", "v", false, "list unchanged files too")
+46 -1
View File
@@ -504,7 +504,7 @@
{
"type": "timeseries",
"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": {
"type": "prometheus",
"uid": "llmgw-prom"
@@ -908,6 +908,51 @@
"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"
}
}
}
]
}
+3
View File
@@ -2,6 +2,9 @@
Description=llmgw node agent
After=network-online.target
Wants=network-online.target
# Never given up on: a new release that fails its first starts, or an upgrade
# that restarts it twice in a minute, would otherwise leave the node off.
StartLimitIntervalSec=0
[Service]
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
+340 -3
View File
@@ -110,6 +110,9 @@
{
"name": "restart"
},
{
"name": "routing"
},
{
"name": "settings"
},
@@ -2387,7 +2390,7 @@
"delete": {
"operationId": "deleteModelsName",
"summary": "Remove a model added at runtime",
"description": "A model declared in the cluster's settings is refused: it is changed there.",
"description": "A model declared in the cluster's settings is refused: it is changed there. With purge, its files go too: every version of it the mirror holds (a peer mirror follows), and the nodes' copies once its replicas have stopped, waiting up to a minute for them; what another model uses stays. The answer is then a PurgeView.",
"tags": [
"models"
],
@@ -2401,6 +2404,14 @@
"type": "string"
}
},
{
"name": "purge",
"in": "query",
"description": "Delete its files from the mirror and the nodes as well.",
"schema": {
"type": "boolean"
}
},
{
"$ref": "#/components/parameters/Actor"
}
@@ -2722,7 +2733,7 @@
"/api/v1/models/{name}/context": {
"post": {
"operationId": "postModelsNameContext",
"summary": "Change a served model's context: the catalog, its engines' arguments and memory, then its replicas restarted",
"summary": "Change a served model's context and longest answer: the catalog, its engines' arguments and memory, then its replicas restarted; max_output alone changes only the catalog and restarts nothing",
"tags": [
"models"
],
@@ -3400,6 +3411,89 @@
}
}
},
"/api/v1/models/{name}/start": {
"post": {
"operationId": "postModelsNameStart",
"summary": "Run a stopped model again, scaled as it was",
"tags": [
"models"
],
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"description": "The model's name in the catalog.",
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/Actor"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScaleResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/models/{name}/stop": {
"post": {
"operationId": "postModelsNameStop",
"summary": "Take a model out of service and keep it",
"description": "None of its replicas run, and requests are told an administrator stopped it; what it was scaled to is kept for start.",
"tags": [
"models"
],
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"description": "The model's name in the catalog.",
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/Actor"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ScaleResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/models/{name}/target": {
"get": {
"operationId": "getModelsNameTarget",
@@ -4702,6 +4796,77 @@
}
}
},
"/api/v1/routing": {
"get": {
"operationId": "getRouting",
"summary": "The Ready replicas a standby routes to",
"description": "Read by a standby control plane when router.active_active is on: each replica's address, model, profile, pool and weight, without its key, and which catalog is in force.",
"tags": [
"routing"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/control.Routing"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/routing/activity": {
"post": {
"operationId": "postRoutingActivity",
"summary": "A standby's router reports what it used",
"description": "When it last sent each replica a request, and the on-demand models it was asked for, so the leader does not evict a replica busy behind a standby. Answered with the routing, so one request a tick does both.",
"tags": [
"routing"
],
"parameters": [
{
"$ref": "#/components/parameters/Actor"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/control.Activity"
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/control.Routing"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/settings": {
"get": {
"operationId": "getSettings",
@@ -6394,6 +6559,10 @@
"percent": {
"type": "integer",
"description": "Percent is absent rather than zero when the total is unknown: an invented percentage that stops moving is worse than a byte count that keeps rising."
},
"phase": {
"type": "string",
"description": "Phase is empty while the bytes arrive, \"checking\" while a set's files are checked against the mirror's checksums."
}
}
},
@@ -7859,6 +8028,10 @@
"max_output": {
"type": "integer"
},
"stopped": {
"type": "boolean",
"description": "Stopped is a model an administrator took out of service, kept."
},
"canary": {
"$ref": "#/components/schemas/catalog.Canary",
"description": "Canary is a share of its requests sent to a candidate; CanaryOf is set on the candidate, naming the model it is a canary of."
@@ -8939,6 +9112,10 @@
},
"on_demand": {
"type": "boolean"
},
"stopped": {
"type": "boolean",
"description": "Stopped is a model taken out of service, kept, until it is started."
}
}
},
@@ -9593,6 +9770,42 @@
}
}
},
"accounting.Counts": {
"type": "object",
"description": "Counts are one limiter's counts by counter: this minute's and the one before, as absolute figures, so reporting them twice changes nothing.",
"properties": {
"minute": {
"type": "string",
"format": "date-time"
},
"current": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
}
},
"previous": {
"type": "object",
"additionalProperties": {
"type": "integer",
"format": "int64"
}
}
}
},
"accounting.Rates": {
"type": "object",
"description": "Rates are an accountant's two limiters' counts.",
"properties": {
"requests": {
"$ref": "#/components/schemas/accounting.Counts"
},
"tokens": {
"$ref": "#/components/schemas/accounting.Counts"
}
}
},
"bench.Capacity": {
"type": "object",
"description": "Capacity is what one replica was measured to serve, kept so the measurement outlives the terminal it was printed in.\n\nThe conclusion of a run, not the run. The ramp itself — every concurrency level, its latencies, its errors — is worth reading once, while deciding whether the run was sound; it is not worth carrying around afterwards, and storing it would grow without bound as a model is re-benchmarked. What a later reader needs is the number and enough provenance to judge whether it is still true.",
@@ -10064,6 +10277,10 @@
"scaling": {
"$ref": "#/components/schemas/catalog.Scaling"
},
"stopped": {
"$ref": "#/components/schemas/catalog.Scaling",
"description": "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."
},
"class": {
"type": "string",
"description": "Class is how much this model's capacity is worth against another's, when there is not enough for both: critical, standard or opportunistic. Empty is standard, or opportunistic for a model loaded on demand. See EffectiveClass."
@@ -10447,6 +10664,77 @@
}
}
},
"control.Activity": {
"type": "object",
"description": "Activity is what a standby's router reports back: when it last sent each replica a request, and the on-demand models it was asked for. The leader evicts and loads by these, and without them a replica busy behind a standby looks idle to the one machine that decides.",
"properties": {
"from": {
"type": "string",
"description": "From names the standby, so its counts replace its own last ones and are not given back to it."
},
"last_used": {
"type": "object",
"additionalProperties": {
"type": "string",
"format": "date-time"
}
},
"want": {
"type": "array",
"items": {
"type": "string"
}
},
"rates": {
"$ref": "#/components/schemas/accounting.Rates",
"description": "Rates are its router's own counts against each limit."
}
}
},
"control.RoutedReplica": {
"type": "object",
"description": "RoutedReplica is a Ready replica as a router needs it: where it is, what it serves and how much it may take. Not its key — every control plane derives that from the replica's id and the cluster's secret, so none travels.",
"properties": {
"id": {
"type": "string"
},
"model": {
"type": "string"
},
"profile": {
"type": "string"
},
"pool": {
"type": "string"
},
"endpoint": {
"type": "string"
},
"weight": {
"type": "number"
}
}
},
"control.Routing": {
"type": "object",
"description": "Routing is what a standby that answers requests itself reads from the leader: the replicas it may send to, and which catalog they belong to.",
"properties": {
"replicas": {
"type": "array",
"items": {
"$ref": "#/components/schemas/control.RoutedReplica"
}
},
"catalog": {
"type": "string",
"description": "Catalog changes whenever the leader puts a catalog in force. Only compared with the last one a standby saw, never with its own: a model added, scaled or given a canary is in the store, and the standby reads it again from there."
},
"rates": {
"$ref": "#/components/schemas/accounting.Rates",
"description": "Rates are what every other control plane's router has counted against each limit, the leader's among them: added to the asker's own, they make a key's limits the cluster's."
}
}
},
"eval.Live": {
"type": "object",
"description": "Live is a run as it stands: one running, with how many of its cases are done, or one finished.",
@@ -10912,6 +11200,30 @@
}
}
},
"inventory.GPUProfile": {
"type": "object",
"description": "GPUProfile is a card's profiling counters, in percent; -1 is not reported. UtilPercent is the share of time any kernel ran, and a kernel on one SM of a hundred counts in full; these say how much of the chip it ran on.",
"properties": {
"sm_active": {
"type": "number",
"description": "SMActive is the share of time the SMs had work, averaged over all of them; SMOccupancy how full those SMs were with warps."
},
"sm_occupancy": {
"type": "number"
},
"tensor_active": {
"type": "number",
"description": "TensorActive is the tensor cores, DRAMActive the memory interface."
},
"dram_active": {
"type": "number"
},
"source": {
"type": "string",
"description": "Source is what read them: \"gpm\" (NVML, Hopper and later) or \"dcgm\"."
}
}
},
"inventory.GPUSensor": {
"type": "object",
"description": "GPUSensor is one card's heat and power, and what is throttling it.",
@@ -10959,6 +11271,10 @@
"wear": {
"$ref": "#/components/schemas/inventory.GPUWear",
"description": "Wear is what wears the card out, read every minute; nil from an agent that does not read it."
},
"profile": {
"$ref": "#/components/schemas/inventory.GPUProfile",
"description": "Profile is how much of the chip the work uses, which UtilPercent does not say; nil where nothing on the node can read it."
}
}
},
@@ -11083,6 +11399,13 @@
"expert_parallel": {
"type": "boolean",
"description": "ExpertParallel spreads a mixture-of-experts model's experts over the cards, whole, rather than slicing each across them: vLLM's --enable-expert-parallel, for a model of experts on more than one card."
},
"strata_family": {
"type": "string",
"description": "StrataFamily and StrataSize are Strata's --family and --size, for an option on Strata: one card, and the experts in the node's RAM, which NeedGB and HaveGB are then about."
},
"strata_size": {
"type": "string"
}
}
},
@@ -11131,6 +11454,13 @@
"max_output": {
"type": "integer"
},
"output_only": {
"type": "boolean",
"description": "OutputOnly is a change of the longest answer alone, MaxOutputBefore what it was (0 no limit); nothing was started again."
},
"max_output_before": {
"type": "integer"
},
"trained": {
"type": "integer"
},
@@ -11175,13 +11505,16 @@
},
"modelserve.ContextRequest": {
"type": "object",
"description": "ContextRequest is the context to change to: tokens one request may use, and for llama.cpp how many at once (its pool is the two multiplied).",
"description": "ContextRequest is the context to change to: tokens one request may use, and for llama.cpp how many at once (its pool is the two multiplied). MaxOutput is the longest answer, which may be changed with it or alone: Tokens 0 with a MaxOutput changes only that, in the catalog, which the router reads it from — nothing is started again.",
"properties": {
"tokens": {
"type": "integer"
},
"slots": {
"type": "integer"
},
"max_output": {
"type": "integer"
}
}
},
@@ -11433,6 +11766,10 @@
"type": "boolean",
"description": "Fallbacks adds the next best ways, lower in priority, so a model whose first choice is busy runs on another rather than waiting."
},
"engine": {
"type": "string",
"description": "Engine is the engine the file runs on: \"strata\" for a family Strata runs, \"llamacpp\" to keep it off Strata; empty, the plan's best."
},
"name": {
"type": "string",
"description": "Name is the catalog name; the repository suggests one otherwise."
+4
View File
@@ -2890,6 +2890,10 @@
"writable": {
"type": "boolean",
"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."
}
}
},
+198 -14
View File
@@ -703,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 |
| 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) |
| 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
node gets the same dependencies whenever it is built, and installed with
@@ -745,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.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
Which build a GPU node installs is chosen on the cluster page, in the **vLLM
@@ -897,6 +993,24 @@ 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
engine's own downloader are all valid, and which you want depends on how many
nodes you have.
@@ -1629,23 +1743,61 @@ 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
a peer behind a private CA needs that CA in the system's trust store.
### Changing a served model's context
How much one request may use is set when a model is served. To change it
afterwards:
### Stopping a model, and removing one with its files
```
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 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
```
The catalog, the engine's arguments (`--ctx-size` and `--parallel` for
llama.cpp, `--max-model-len` for vLLM, `--context-length` for SGLang) and
the memory its KV cache takes change together. A context longer than the
- **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…. A model declared in the cluster's
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
@@ -1937,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
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
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,
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
Beside time to the first token, every streamed answer is timed per token:
@@ -2583,6 +2754,12 @@ is down, and new ones wait for it. With a second, the cluster carries on.
```
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
@@ -2644,9 +2821,16 @@ Caddy doing that for three control planes, and
`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
time it leads and fetches a model.
- **Limits are per process.** Rate limits are counted in memory, and clients
reach the leader through the floating address, so a failover starts each
key's minute afresh.
- **Standbys can answer clients too.** With `router.active_active: true`
(`llmgw ctl config set router.active_active true`) a standby answers `/v1`
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
their own load balancer in front.
+16 -14
View File
@@ -3,9 +3,9 @@
A knowledge base is a collection of a company's documents that the cluster
can answer from. People put their documents into it from their own machines
with `llmgw-kb`. The tool reads the files on that machine, cuts them into
passages ("chunks"), has the gateway embed each passage with the person's own
key, and sends the text and the vectors. The files themselves never leave
the machine.
passages ("chunks") and sends their text; the gateway embeds each passage
with the person's own key and keeps the text and the vectors. The files
themselves never leave the machine.
## Why retrieval, and not training
@@ -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
```
The people's questions then go first, and the import waits behind them. A
request that waits as long as its band allows is answered 504 with
`Retry-After`, and `llmgw-kb` sends it again rather than counting the
passage as failed. It gives up after 15 minutes.
The people's questions then go first, and the import waits behind them. The
gateway embeds a document's passages 32 at a time, four batches at once,
enough to fill an embedding replica's slots; a batch told to wait (`Retry-After`)
waits up to five minutes before the document is answered the same, and
`llmgw-kb` sends it again rather than counting it as failed. It gives up
after 15 minutes.
## For everyone: `llmgw-kb`
@@ -225,6 +227,7 @@ not changed.
| `--include`, `--exclude` | Glob patterns. `*.pdf` matches a file name; `reports/**/*.xlsx` matches a path. |
| `--hidden` | Reads hidden files and folders too (left out by default). |
| `-v` | Lists unchanged files too. |
| `--embed-here` | Embeds the passages from this machine and sends the vectors. With a gateway that does not embed them itself it does so anyway. |
Documents are named by their path under the folder, with forward slashes,
on every system. With several folders, each folder's own name comes first.
@@ -259,13 +262,12 @@ scanned page with no OCR.
### What leaves your machine
For each new or changed document, two things leave the machine, both over
the gateway's TLS connection:
- **The passages, to be embedded.** They go through `/v1/embeddings` like
any request of yours. Your key's limits, budget and guardrails apply.
- **The document itself.** Its name, its file's SHA-256 digest and size,
and the passages with their vectors.
For each new or changed document, one request leaves the machine, over the
gateway's TLS connection: its name, its file's SHA-256 digest and size, and
its passages' text. The gateway embeds the passages through `/v1/embeddings`
as you, so your key's limits, budget and guardrails apply as if you had
asked. That is about a kilobyte a passage; embedding here (`--embed-here`)
brings each vector down and sends it up again, some thirty times more.
The file itself is never sent. `--dry-run` shows exactly what would be.
+60
View File
@@ -126,6 +126,7 @@ rather than repeated.
| `name` | `string` | [Federation](#federation) |
| `interval` | `time.Duration` | [Federation](#federation) |
| `enabled` | `bool` | [FirewallSettings](#firewallsettings) |
| `binary` | `string` | [FreeTokenConfig](#freetokenconfig) |
| `min_compute_capability` | `float64` | [GPUNeed](#gpuneed) |
| `min_memory_gb` | `int` | [GPUNeed](#gpuneed) |
| `format` | `string` | [GuardModel](#guardmodel) |
@@ -192,6 +193,7 @@ rather than repeated.
| `allow_pickle` | `bool` | [Model](#model) |
| `profiles` | `[]Profile` | [Model](#model) |
| `scaling` | `Scaling` | [Model](#model) |
| `stopped` | `*Scaling` | [Model](#model) |
| `class` | `string` | [Model](#model) |
| `owner` | `string` | [Model](#model) |
| `external` | `*External` | [Model](#model) |
@@ -233,6 +235,7 @@ rather than repeated.
| `ib_root` | `string` | [Options](#options) |
| `prefer_nvml` | `bool` | [Options](#options) |
| `skip_gpus` | `bool` | [Options](#options) |
| `dcgm_exporter` | `string` | [Options](#options) |
| `repo` | `string` | [Origin](#origin) |
| `revision` | `string` | [Origin](#origin) |
| `files` | `[]OriginFile` | [Origin](#origin) |
@@ -317,6 +320,7 @@ rather than repeated.
| `batch` | `Batch` | [Router](#router) |
| `cache` | `Cache` | [Router](#router) |
| `trusted_proxies` | `[]string` | [Router](#router) |
| `active_active` | `bool` | [Router](#router) |
| `timeouts` | `Timeouts` | [Router](#router) |
| `scoring` | `Scoring` | [Router](#router) |
| `admission` | `Admission` | [Router](#router) |
@@ -480,6 +484,7 @@ rather than repeated.
| `sim` | `SimConfig` | [engines.Config](#enginesconfig) |
| `comfyui` | `ComfyUIConfig` | [engines.Config](#enginesconfig) |
| `strata` | `StrataConfig` | [engines.Config](#enginesconfig) |
| `freetoken` | `FreeTokenConfig` | [engines.Config](#enginesconfig) |
| `enabled` | `*bool` | [guard.Config](#guardconfig) |
| `default` | `[]string` | [guard.Config](#guardconfig) |
| `policies` | `map[string]Policy` | [guard.Config](#guardconfig) |
@@ -1315,6 +1320,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
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
### `min_compute_capability`
@@ -1824,6 +1839,16 @@ no safetensors or GGUF form and whose files were checked.
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`
`string`
@@ -2171,6 +2196,15 @@ is used, is the more exact source of the peers.
SkipGPUs omits GPU discovery. A node without nvidia-smi is already
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
### `repo`
@@ -2777,6 +2811,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
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`
@@ -4021,6 +4071,16 @@ 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
### `enabled`
+33 -5
View File
@@ -78,10 +78,41 @@ func (a *Accountant) RestoreBudgets(ctx context.Context, s store.Store, period s
if start.IsZero() {
return nil
}
rows, err := s.SpendSinceByScope(ctx, start)
spend, keys, err := storedSpend(ctx, s, start)
if err != nil {
return fmt.Errorf("restoring budgets: %w", err)
}
a.budgets.Restore(period, spend)
a.log.Info("restored budgets from the store",
"keys", keys, "counters", len(spend), "since", start)
return nil
}
// CatchUpBudgets raises each counter to what the store has recorded, where
// that is more. Run every so often by a router that is one of several taking
// requests: the others' spend reaches it only through the store. Never
// lowers a counter, which leads the store by whatever this process has not
// written yet.
func (a *Accountant) CatchUpBudgets(ctx context.Context, s store.Store, period store.Period) error {
start := PeriodStart(period, time.Now())
if start.IsZero() {
return nil
}
spend, _, err := storedSpend(ctx, s, start)
if err != nil {
return fmt.Errorf("reading budgets: %w", err)
}
a.budgets.Raise(period, spend)
return nil
}
// storedSpend is the spend since start the store has recorded, by counter,
// and how many keys it came from.
func storedSpend(ctx context.Context, s store.Store, start time.Time) (map[string]int64, int, error) {
rows, err := s.SpendSinceByScope(ctx, start)
if err != nil {
return nil, 0, err
}
// Every scope a key sits under, not just the key. A budget owned by a team
// is one counter fed by every key beneath it, and restoring only per-key
// totals would bring the control plane back up believing the team had spent
@@ -113,10 +144,7 @@ func (a *Accountant) RestoreBudgets(ctx context.Context, s store.Store, period s
}
}
}
a.budgets.Restore(period, spend)
a.log.Info("restored budgets from the store",
"keys", len(rows), "counters", len(spend), "since", start)
return nil
return spend, len(rows), nil
}
// Reservation is what Check returns: the estimate it charged, so Record can
+16
View File
@@ -216,6 +216,22 @@ func TestBudgetSurvivesARestart(t *testing.T) {
}
}
func TestCatchingUpRaisesButNeverLowers(t *testing.T) {
// Another router's spend arrives through the store; this one's own, not
// written yet, must not be wiped by a store that is behind.
b := NewBudgets()
b.Add("mine", store.PeriodMonth, 900)
b.Add("shared", store.PeriodMonth, 100)
b.Raise(store.PeriodMonth, map[string]int64{"mine": 400, "shared": 700, "theirs": 50})
for key, want := range map[string]int64{"mine": 900, "shared": 700, "theirs": 50} {
if got := b.Spent(key, store.PeriodMonth); got != want {
t.Errorf("%s: spent %d, want %d", key, got, want)
}
}
}
func TestBudgetsResetAtThePeriodBoundary(t *testing.T) {
b := NewBudgets()
now := time.Date(2026, 1, 31, 23, 59, 0, 0, time.UTC)
+13
View File
@@ -61,6 +61,19 @@ func (b *Budgets) Restore(period store.Period, spend map[string]int64) {
}
}
// Raise lifts counters to the spend given, where it is more.
func (b *Budgets) Raise(period store.Period, spend map[string]int64) {
start := PeriodStart(period, b.now())
b.mu.Lock()
defer b.mu.Unlock()
for key, micros := range spend {
if c := b.currentLocked(key, start); micros > c.micros {
c.micros = micros
b.spend[key] = c
}
}
}
// Spent returns a key's spend in the current period, resetting it if the period
// has rolled over.
func (b *Budgets) Spent(keyID string, period store.Period) int64 {
+10 -3
View File
@@ -25,7 +25,11 @@ type Limiter struct {
type window struct {
// current and previous are counts in this minute and the one before.
current, previous int64
start time.Time
// othersCurrent and othersPrevious are the same counts on the other
// control planes taking requests, as last heard: what makes a limit the
// cluster's rather than each process's.
othersCurrent, othersPrevious int64
start time.Time
}
// NewLimiter builds an empty limiter.
@@ -104,21 +108,24 @@ func (w *window) roll(now time.Time) {
return
case start.Sub(w.start) == windowLen:
w.previous, w.current = w.current, 0
w.othersPrevious, w.othersCurrent = w.othersCurrent, 0
default:
// More than one window has passed with no traffic.
w.previous, w.current = 0, 0
w.othersPrevious, w.othersCurrent = 0, 0
}
w.start = start
}
// rate weights the previous window by how much of it is still in view.
func (w *window) rate(now time.Time) float64 {
cur, prev := w.current+w.othersCurrent, w.previous+w.othersPrevious
elapsed := now.Sub(w.start)
if elapsed >= windowLen {
return float64(w.current)
return float64(cur)
}
remaining := 1 - float64(elapsed)/float64(windowLen)
return float64(w.previous)*remaining + float64(w.current)
return float64(prev)*remaining + float64(cur)
}
// Forget drops a key's window. Used when a key is revoked; keeping it would
+110
View File
@@ -0,0 +1,110 @@
package accounting
import "time"
// Counts are one limiter's counts by counter: this minute's and the one
// before, as absolute figures, so reporting them twice changes nothing.
type Counts struct {
Minute time.Time `json:"minute"`
Current map[string]int64 `json:"current,omitempty"`
Previous map[string]int64 `json:"previous,omitempty"`
}
// Add sums other into c, aligned to c's minute: counts of a minute that is
// c's previous one land there, older ones are dropped. Clocks a second apart
// at a minute's turn are why.
func (c *Counts) Add(other Counts) {
switch {
case other.Minute.Equal(c.Minute):
c.Current = addInto(c.Current, other.Current)
c.Previous = addInto(c.Previous, other.Previous)
case other.Minute.Add(windowLen).Equal(c.Minute):
c.Previous = addInto(c.Previous, other.Current)
}
}
func addInto(dst, src map[string]int64) map[string]int64 {
if len(src) == 0 {
return dst
}
if dst == nil {
dst = make(map[string]int64, len(src))
}
for k, v := range src {
dst[k] += v
}
return dst
}
// Counts reads this limiter's own counts, without the others'.
func (l *Limiter) Counts() Counts {
now := l.now()
out := Counts{Minute: now.Truncate(windowLen), Current: map[string]int64{}, Previous: map[string]int64{}}
l.mu.Lock()
defer l.mu.Unlock()
for k, w := range l.keys {
w.roll(now)
if w.current > 0 {
out.Current[k] = w.current
}
if w.previous > 0 {
out.Previous[k] = w.previous
}
}
return out
}
// SetOthers replaces what the other control planes have counted. A counter
// they did not mention has nothing from them.
func (l *Limiter) SetOthers(c Counts) {
now := l.now()
aligned := Counts{Minute: now.Truncate(windowLen)}
aligned.Add(c)
l.mu.Lock()
defer l.mu.Unlock()
for k, w := range l.keys {
w.roll(now)
w.othersCurrent, w.othersPrevious = aligned.Current[k], aligned.Previous[k]
delete(aligned.Current, k)
delete(aligned.Previous, k)
}
// Counters only others have used yet: a key spread over the cluster is
// limited here from its first request.
for k := range aligned.Current {
w := l.windowLocked(k, now)
w.othersCurrent, w.othersPrevious = aligned.Current[k], aligned.Previous[k]
delete(aligned.Previous, k)
}
for k, v := range aligned.Previous {
l.windowLocked(k, now).othersPrevious = v
}
}
// Rates are an accountant's two limiters' counts.
type Rates struct {
Requests Counts `json:"requests"`
Tokens Counts `json:"tokens"`
}
// Add sums other into r.
func (r *Rates) Add(other Rates) {
r.Requests.Add(other.Requests)
r.Tokens.Add(other.Tokens)
}
// NewRates is an empty sum for the minute now is in.
func NewRates(now time.Time) Rates {
m := now.Truncate(windowLen)
return Rates{Requests: Counts{Minute: m}, Tokens: Counts{Minute: m}}
}
// Rates reads this process's own request and token counts.
func (a *Accountant) Rates() Rates {
return Rates{Requests: a.requests.Counts(), Tokens: a.tokens.Counts()}
}
// SetOtherRates puts the other control planes' counts in force here.
func (a *Accountant) SetOtherRates(r Rates) {
a.requests.SetOthers(r.Requests)
a.tokens.SetOthers(r.Tokens)
}
+71
View File
@@ -0,0 +1,71 @@
package accounting
import (
"testing"
"time"
)
// Two control planes each allowing a key six of its ten a minute: once each
// has heard the other's count, neither allows another.
func TestALimitIsTheClustersOnceTheCountsAreShared(t *testing.T) {
now := time.Date(2026, 11, 1, 12, 0, 10, 0, time.UTC)
a, b := NewLimiter(), NewLimiter()
a.now, b.now = func() time.Time { return now }, func() time.Time { return now }
for range 6 {
if !a.Allow("k", 10, 1) || !b.Allow("k", 10, 1) {
t.Fatal("refused within the limit")
}
}
a.SetOthers(b.Counts())
b.SetOthers(a.Counts())
if a.Allow("k", 10, 1) || b.Allow("k", 10, 1) {
t.Error("twelve of ten allowed once both knew")
}
// Reporting the same counts again changes nothing: they are absolute.
a.SetOthers(b.Counts())
if got := a.Rate("k"); got != 12 {
t.Errorf("rate %v after the same counts twice", got)
}
// What the others counted is never charged back to them.
if c := a.Counts(); c.Current["k"] != 6 {
t.Errorf("own count %d", c.Current["k"])
}
}
// A key only the others have used is limited here from its first request,
// and their counts move into the previous minute with the clock.
func TestOthersCountsFollowTheMinute(t *testing.T) {
now := time.Date(2026, 11, 1, 12, 0, 50, 0, time.UTC)
a, b := NewLimiter(), NewLimiter()
a.now, b.now = func() time.Time { return now }, func() time.Time { return now }
for range 10 {
b.Allow("k", 10, 1)
}
a.SetOthers(b.Counts())
if a.Allow("k", 10, 1) {
t.Error("a key the cluster has used up was allowed where it had not been seen")
}
now = now.Add(20 * time.Second) // 12:01:10, a sixth into the next minute
if got := a.Rate("k"); got < 8 || got > 8.5 {
t.Errorf("rate %v: the others' last minute should weigh five sixths", got)
}
// Counts of a minute ago, sent late, land in the previous minute.
old := b.Counts()
old.Minute = now.Truncate(time.Minute).Add(-time.Minute)
old.Current, old.Previous = map[string]int64{"k": 3}, nil
a.SetOthers(old)
if got := a.Rate("k"); got < 2 || got > 3 {
t.Errorf("rate %v from late counts", got)
}
}
func TestRatesAddAlignsMinutes(t *testing.T) {
m := time.Date(2026, 11, 1, 12, 1, 0, 0, time.UTC)
sum := NewRates(m)
sum.Add(Rates{Requests: Counts{Minute: m, Current: map[string]int64{"k": 2}, Previous: map[string]int64{"k": 1}}})
sum.Add(Rates{Requests: Counts{Minute: m.Add(-time.Minute), Current: map[string]int64{"k": 5}}})
sum.Add(Rates{Requests: Counts{Minute: m.Add(-2 * time.Minute), Current: map[string]int64{"k": 100}}})
if sum.Requests.Current["k"] != 2 || sum.Requests.Previous["k"] != 6 {
t.Errorf("sum %+v", sum.Requests)
}
}
+14 -5
View File
@@ -47,7 +47,9 @@ import (
// Agent runs on a GPU node.
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
// starts the reconnect backoff again when it ends.
answered atomic.Bool
@@ -185,7 +187,7 @@ func New(cfg Config, httpClient connectHTTPClient, log *slog.Logger) (*Agent, er
logRing: ring,
provKick: make(chan struct{}, 1),
load: discovery.NewLoadSampler(),
sensors: discovery.NewSensorSampler(),
sensors: discovery.NewSensorSampler().WithProfile(discovery.NewProfSampler(cfg.Discovery.DCGMExporter)),
wear: discovery.NewHealthSampler(),
serverList: loadControlPlanes(cfg.Servers, cfg.TLS.Enabled()),
kv: kvevents.NewMirror(),
@@ -796,6 +798,11 @@ func (a *Agent) sampleLoad() *llmgwv1.NodeLoad {
MemoryUsedMb: g.MemoryUsedMB, MemoryTotalMb: g.MemoryTotalMB,
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 {
gs.HasWear = true
gs.PcieReplays, gs.RetiredPagesSbe, gs.RetiredPagesDbe, gs.RetirementPending =
@@ -878,6 +885,7 @@ func (a *Agent) removeWeights(paths []string) {
a.log.Warn("could not remove cached weights", "path", p, "err", err)
continue
}
_ = os.Remove(weights.ChecksumPath(abs))
a.log.Info("removed cached weights no model asks for", "path", p)
}
}
@@ -1129,11 +1137,11 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
send(&llmgwv1.ShardStatus{
State: agentsvc.StateToWire(supervisor.Starting),
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, spec.Profile.AllowPickle); err != nil {
if path, err := a.weights().EnsureChecked(ctx, spec.Profile.ModelPath, spec.Profile.AllowPickle, spec.Profile.ModelSHA256); err != nil {
stop()
report(supervisor.Failed, err.Error(), nil)
return
@@ -1152,7 +1160,8 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
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}})
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()
+1 -1
View File
@@ -68,7 +68,7 @@ func (r *statusRecorder) OnShardCompute(replicaID, shardID string, c reconciler.
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()
defer r.mu.Unlock()
r.fetches = append(r.fetches, fetchReport{replicaID, shardID, source, done, total})
+93
View File
@@ -0,0 +1,93 @@
package discovery
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
)
// CUDAToolkit is the root of an installed CUDA toolkit of this major version
// — the directory whose bin holds nvcc — or "" when there is none. Engines
// that compile their kernels where they run (FreeToken, ComfyUI's compiled
// nodes) need one matching the CUDA their torch was built for, and a
// toolkit of another major version compiles code that torch cannot load.
//
// CUDA_HOME is looked at first, then /usr/local/cuda, then every
// /usr/local/cuda-<version>, newest first.
func CUDAToolkit(major int) string {
return cudaToolkitIn(major, os.Getenv("CUDA_HOME"), "/usr/local")
}
func cudaToolkitIn(major int, home, root string) string {
var dirs []string
if home != "" {
dirs = append(dirs, home)
}
dirs = append(dirs, filepath.Join(root, "cuda"))
versioned, _ := filepath.Glob(filepath.Join(root, "cuda-*"))
slices.SortFunc(versioned, func(a, b string) int { return versionCompare(b, a) })
dirs = append(dirs, versioned...)
for _, d := range dirs {
if _, err := os.Stat(filepath.Join(d, "bin", "nvcc")); err != nil {
continue
}
if toolkitMajor(d) == major {
return d
}
}
return ""
}
// toolkitMajor is a toolkit's major version, from the version.json every
// toolkit since 11.1 carries, or from nvcc where it has none; 0 when neither
// says.
func toolkitMajor(dir string) int {
if b, err := os.ReadFile(filepath.Join(dir, "version.json")); err == nil { //nolint:gosec // a toolkit's own file
var v struct {
CUDA struct {
Version string `json:"version"`
} `json:"cuda"`
}
if json.Unmarshal(b, &v) == nil && v.CUDA.Version != "" {
n, _ := strconv.Atoi(strings.SplitN(v.CUDA.Version, ".", 2)[0])
return n
}
}
//nolint:gosec // nvcc of a toolkit found on disk
out, err := exec.Command(filepath.Join(dir, "bin", "nvcc"), "--version").Output()
if err != nil {
return 0
}
if m := nvccRelease.FindSubmatch(out); m != nil {
n, _ := strconv.Atoi(string(m[1]))
return n
}
return 0
}
var nvccRelease = regexp.MustCompile(`release (\d+)\.`)
// versionCompare orders two paths ending in dotted versions: cuda-13.10 is
// newer than cuda-13.4, which comparing them as text gets wrong.
func versionCompare(a, b string) int {
pa := strings.Split(strings.TrimPrefix(filepath.Base(a), "cuda-"), ".")
pb := strings.Split(strings.TrimPrefix(filepath.Base(b), "cuda-"), ".")
for i := range max(len(pa), len(pb)) {
var x, y int
if i < len(pa) {
x, _ = strconv.Atoi(pa[i])
}
if i < len(pb) {
y, _ = strconv.Atoi(pb[i])
}
if x != y {
return x - y
}
}
return 0
}
@@ -0,0 +1,48 @@
package discovery
import (
"os"
"path/filepath"
"testing"
)
func toolkit(t *testing.T, root, name, version string) string {
t.Helper()
d := filepath.Join(root, name)
put(t, d, "bin/nvcc", "#!/bin/sh")
if version != "" {
put(t, d, "version.json", `{"cuda":{"name":"CUDA SDK","version":"`+version+`"}}`)
}
return d
}
func TestTheNewestToolkitOfTheMajorVersionAskedForIsFound(t *testing.T) {
root := t.TempDir()
toolkit(t, root, "cuda-12.8", "12.8.1")
toolkit(t, root, "cuda-13.4", "13.4.0")
want := toolkit(t, root, "cuda-13.10", "13.10.0")
// /usr/local/cuda is a 12: a toolkit of the wrong major version is not
// one torch built for 13 can load what it compiles.
toolkit(t, root, "cuda", "12.8.1")
if got := cudaToolkitIn(13, "", root); got != want {
t.Errorf("got %q, want %q", got, want)
}
if got := cudaToolkitIn(12, "", root); got != filepath.Join(root, "cuda") {
t.Errorf("12: got %q", got)
}
// CUDA_HOME first, when it is one.
home := toolkit(t, t.TempDir(), "mine", "13.0.2")
if got := cudaToolkitIn(13, home, root); got != home {
t.Errorf("CUDA_HOME: got %q", got)
}
if got := cudaToolkitIn(14, "", root); got != "" {
t.Errorf("14: got %q", got)
}
// A directory without nvcc is not a toolkit.
if err := os.MkdirAll(filepath.Join(root, "cuda-13.99"), 0o755); err != nil {
t.Fatal(err)
}
if got := cudaToolkitIn(13, "", root); got != want {
t.Errorf("with an empty cuda-13.99: got %q", got)
}
}
+5
View File
@@ -28,6 +28,11 @@ type Options struct {
// SkipGPUs omits GPU discovery. A node without nvidia-smi is already
// found to have none, so this is for one whose driver is broken.
SkipGPUs bool `koanf:"skip_gpus"`
// DCGMExporter is the dcgm-exporter metrics URL read for how much of
// each card's chip is in use, on cards NVML's GPM does not cover (before
// Hopper). Empty is http://127.0.0.1:9400/metrics; "off" reads none. A
// node without one costs a refused connection every five minutes.
DCGMExporter string `koanf:"dcgm_exporter"`
// ModelDirs are scanned for weights. Empty means do not look.
ModelDirs []string `koanf:"-"`
}
+6
View File
@@ -0,0 +1,6 @@
//go:build !nvml
package discovery
// newGPM has nothing to read GPM with in a build without the nvml tag.
func newGPM() gpmSource { return nil }
+124
View File
@@ -0,0 +1,124 @@
//go:build nvml
package discovery
import (
"sync"
"github.com/NVIDIA/go-nvml/pkg/nvml"
)
// nvmlGPM reads GPM, NVML's own profiling counters, on the cards that have
// them: Hopper and later. Each read takes a sample and works out the metrics
// against the one before, so NVML stays initialised between reads.
type nvmlGPM struct {
mu sync.Mutex
opened bool
failed bool
cards map[int]*gpmCard
}
type gpmCard struct {
dev nvml.Device
prev, cur nvml.GpmSample
havePrevious bool
}
func newGPM() gpmSource { return &nvmlGPM{} }
// The GPM metrics read, in the order they are asked for.
var gpmMetrics = []struct {
id nvml.GpmMetricId
set func(*GPUProfile, float64)
}{
{nvml.GPM_METRIC_SM_UTIL, func(p *GPUProfile, v float64) { p.SMActive = v }},
{nvml.GPM_METRIC_SM_OCCUPANCY, func(p *GPUProfile, v float64) { p.SMOccupancy = v }},
{nvml.GPM_METRIC_ANY_TENSOR_UTIL, func(p *GPUProfile, v float64) { p.TensorActive = v }},
{nvml.GPM_METRIC_DRAM_BW_UTIL, func(p *GPUProfile, v float64) { p.DRAMActive = v }},
}
func (g *nvmlGPM) read() map[int]GPUProfile {
g.mu.Lock()
defer g.mu.Unlock()
if g.failed {
return nil
}
if !g.opened && !g.open() {
g.failed = true
return nil
}
out := map[int]GPUProfile{}
for idx, c := range g.cards {
if c.cur.Get(c.dev) != nvml.SUCCESS {
// A card in MIG mode, or one that has gone: no sample, and the
// next must not be measured against an old one.
c.havePrevious = false
continue
}
if c.havePrevious {
if p, ok := gpmBetween(c.prev, c.cur); ok {
out[idx] = p
}
}
c.prev, c.cur = c.cur, c.prev
c.havePrevious = true
}
return out
}
// open finds the cards with GPM. False is NVML not starting, or no card
// having it, and the reader then never tries again.
func (g *nvmlGPM) open() bool {
if nvml.Init() != nvml.SUCCESS {
return false
}
g.cards = map[int]*gpmCard{}
n, ret := nvml.DeviceGetCount()
if ret != nvml.SUCCESS {
n = 0
}
for i := range n {
dev, ret := nvml.DeviceGetHandleByIndex(i)
if ret != nvml.SUCCESS {
continue
}
sup, ret := dev.GpmQueryDeviceSupport()
if ret != nvml.SUCCESS || sup.IsSupportedDevice == 0 {
continue
}
a, ret := nvml.GpmSampleAlloc()
if ret != nvml.SUCCESS {
continue
}
b, ret := nvml.GpmSampleAlloc()
if ret != nvml.SUCCESS {
_ = a.Free()
continue
}
g.cards[i] = &gpmCard{dev: dev, prev: a, cur: b}
}
if len(g.cards) == 0 {
_ = nvml.Shutdown()
return false
}
g.opened = true
return true
}
// gpmBetween is the profile over the time between two samples.
func gpmBetween(earlier, later nvml.GpmSample) (GPUProfile, bool) {
q := nvml.GpmMetricsGetType{NumMetrics: uint32(len(gpmMetrics)), Sample1: earlier, Sample2: later}
for i, m := range gpmMetrics {
q.Metrics[i].MetricId = uint32(m.id)
}
if nvml.GpmMetricsGet(&q) != nvml.SUCCESS {
return GPUProfile{}, false
}
p := GPUProfile{SMActive: -1, SMOccupancy: -1, TensorActive: -1, DRAMActive: -1, Source: "gpm"}
for i, m := range gpmMetrics {
if r := q.Metrics[i]; r.NvmlReturn == uint32(nvml.SUCCESS) && r.Value >= 0 {
m.set(&p, r.Value)
}
}
return p, true
}
+176
View File
@@ -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 ""
}
+107
View File
@@ -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)
}
}
+19
View File
@@ -49,6 +49,9 @@ type GPUSensor struct {
MemoryUsedMB, MemoryTotalMB float64
SMClockMHz 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
@@ -61,6 +64,8 @@ type SensorSampler struct {
sys string // /sys, or a test's copy of it
smi func(ctx context.Context, fields string) ([]byte, error)
now func() time.Time
// prof reads the cards' profiling counters; nil reads none.
prof *ProfSampler
mu sync.Mutex
last Sensors
@@ -74,6 +79,12 @@ func NewSensorSampler() *SensorSampler {
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.
@@ -101,6 +112,14 @@ func (s *SensorSampler) Sample(ctx context.Context) Sensors {
out.CPUTempC, out.InletTempC = s.hwmon()
out.CPUPowerW = s.rapl(now)
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
return out
}
+9
View File
@@ -50,6 +50,10 @@ type Profile struct {
// 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.
@@ -219,6 +223,7 @@ func NewRegistry(cfg Config) *Registry {
r.Add(&Sim{cfg: cfg.Sim})
r.Add(&ComfyUI{cfg: cfg.ComfyUI})
r.Add(&Strata{cfg: cfg.Strata})
r.Add(&FreeToken{cfg: cfg.FreeToken})
return r
}
@@ -272,6 +277,10 @@ type Config struct {
// 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 ---
+1 -1
View File
@@ -477,7 +477,7 @@ func TestValidateRequiresModelPath(t *testing.T) {
func TestRegistry(t *testing.T) {
r := NewRegistry(Config{})
if got := r.Names(); !slices.Equal(got, []string{"comfyui", "llamacpp", "sglang", "sim", "strata", "trtllm", "vllm"}) {
if got := r.Names(); !slices.Equal(got, []string{"comfyui", "freetoken", "llamacpp", "sglang", "sim", "strata", "trtllm", "vllm"}) {
t.Errorf("registry contents: %v", got)
}
if _, err := r.Get("vllm"); err != nil {
+137
View File
@@ -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
}
+112
View File
@@ -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)
}
}
}
+41 -1
View File
@@ -1,12 +1,16 @@
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.
@@ -93,15 +97,51 @@ func (s *Strata) python() string {
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)
}
if err := requireComputeCapability(p, gpus, 7.0, "strata"); err != nil {
// 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)
}
+44
View File
@@ -1,6 +1,7 @@
package engines
import (
"os"
"path/filepath"
"slices"
"strings"
@@ -67,3 +68,46 @@ func TestStrataSetupFlagsThatMoveFilesAreRefused(t *testing.T) {
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)
}
}
+23 -2
View File
@@ -25,7 +25,7 @@ var envFamilies = []string{
"CUDA_", "NCCL_", "NVIDIA_", "NVTE_", "GLOO_", "UCX_", "TORCH_", "PYTORCH_",
"VLLM_", "SGLANG_", "GGML_", "LLAMA_", "OMP_", "MKL_", "OPENBLAS_", "KMP_",
"HF_", "TRANSFORMERS_", "TOKENIZERS_", "TRITON_", "FLASHINFER_",
"TRTLLM_", "TLLM_",
"TRTLLM_", "TLLM_", "FREETOKEN_",
}
// envExact are single variables that are tuning too.
@@ -33,6 +33,14 @@ var envExact = map[string]bool{
"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
// engine reads and writes.
var envNever = []string{
@@ -59,6 +67,9 @@ func UnsafeEnv(key string) (bool, string) {
return true, "only the variables engines are tuned with are allowed (" +
strings.Join(envFamilies, "*, ") + "*)"
}
if why, ok := envNeverExact[k]; ok {
return true, "it " + why
}
for _, n := range envNever {
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 " +
@@ -157,6 +168,16 @@ var flagsNever = map[string]map[string]string{
"--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
// hyphenated names do not reach them.
"trtllm": {
@@ -223,7 +244,7 @@ func unsafeFlag(engine, flag, _ string) string {
// 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}
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
// given, saying why.
@@ -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)
}
}
+60 -6
View File
@@ -126,12 +126,13 @@ type Recipe struct {
// from PyPI by uv, which is itself installed like llama.cpp: a GitHub release
// checked against its published checksum.
var Recipes = map[string]Recipe{
"llamacpp": {Repo: "ggml-org/llama.cpp", Candidates: llamaCppCandidates},
"vllm": {PyPI: "vllm", Candidates: vllmCandidates},
"sglang": {PyPI: "sglang", Candidates: sglangCandidates},
"comfyui": {Candidates: comfyuiCandidates},
"strata": {Repo: StrataRepo, Candidates: strataCandidates},
"uv": {Repo: "astral-sh/uv", Candidates: uvCandidates},
"llamacpp": {Repo: "ggml-org/llama.cpp", Candidates: llamaCppCandidates},
"vllm": {PyPI: "vllm", Candidates: vllmCandidates},
"sglang": {PyPI: "sglang", Candidates: sglangCandidates},
"comfyui": {Candidates: comfyuiCandidates},
"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
@@ -758,3 +759,56 @@ print("strata version", "` + version + `", "engine", "` + rel.engine + `")`},
{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)
}
+29 -1
View File
@@ -54,9 +54,13 @@ func NewHTTPHealth(timeout time.Duration, opts ...HTTPHealthOption) *HTTPHealth
// Ready reports whether the engine at baseURL is serving model.
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)
}
if body.notReady() {
return fmt.Errorf("health: the engine says %q", body.Status)
}
if !h.requireName || model == "" {
return nil
}
@@ -98,5 +102,29 @@ func (h *HTTPHealth) get(ctx context.Context, url, apiKey string, out any) error
if out == 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)
}
// 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
}
+26
View File
@@ -103,3 +103,29 @@ func TestTheHealthCheckSendsTheEnginesKey(t *testing.T) {
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)
}
}
+69
View File
@@ -4,6 +4,7 @@ import (
"bytes"
"os"
"strconv"
"strings"
"syscall"
)
@@ -63,3 +64,71 @@ func isZombie(pid int) bool {
}
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)
}
}
+16 -1
View File
@@ -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
// 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) {
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
// 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) {
s.log.Warn("SIGKILL failed", "shard", id, "err", err)
}
signalStrays(left, syscall.SIGKILL)
}
select {
@@ -8,6 +8,7 @@ import (
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
"slices"
"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))
}
}
// 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)
}
+22 -2
View File
@@ -35,12 +35,25 @@ func (a *Agent) upgrade(ctx context.Context, m *llmgwv1.UpgradeAgent, out chan<-
select {
case out <- &llmgwv1.AgentMessage{Msg: &llmgwv1.AgentMessage_Upgrade{Upgrade: reply}}:
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)
self, err := upgradeTarget()
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)
cancel()
}
@@ -53,6 +66,13 @@ func (a *Agent) upgrade(ctx context.Context, m *llmgwv1.UpgradeAgent, out chan<-
send()
a.log.Info("upgraded; restarting into the new version", "to", m.GetVersion())
// A moment for the reply to leave before the stream goes.
time.Sleep(500 * time.Millisecond)
time.Sleep(upgradeLinger)
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
+130 -14
View File
@@ -38,8 +38,14 @@ type Progress struct {
Source string
BytesDone 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.
func (p Progress) Percent() int {
if p.BytesTotal <= 0 {
@@ -94,6 +100,13 @@ var ErrNotLocal = errors.New("the weights are not on this node and the source sa
// 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",
trace.WithAttributes(attribute.String("llmgw.weights.source", redact(source))))
defer func() {
@@ -103,7 +116,7 @@ func (f *Fetcher) Ensure(ctx context.Context, source string, allowPickle bool) (
if !allowPickle && !IsSet(source) && Pickled(source) {
return "", refusePickle(source)
}
return f.ensure(ctx, source, allowPickle)
return f.ensure(ctx, source, allowPickle, strings.ToLower(sha256))
}
// tracerName names the spans this package opens.
@@ -125,7 +138,7 @@ func redact(source string) string {
return u.String()
}
func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool) (string, error) {
func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool, want string) (string, error) {
if source == "" {
return "", fmt.Errorf("no source given")
}
@@ -133,7 +146,11 @@ func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool) (
// file, and a set has to be handled before any of it.
if IsSet(source) {
outcome(ctx, "set")
return f.ensureSet(ctx, source, allowPickle)
// 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 !isPath(source) {
@@ -156,10 +173,24 @@ func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool) (
return "", err
}
if _, err := os.Stat(target); err == nil {
outcome(ctx, "cached")
return target, nil
if ok, err := f.cachedIs(target, want); err != 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()
if f.inflight == nil {
f.inflight = map[string]*fetchCall{}
@@ -178,8 +209,7 @@ func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool) (
f.inflight[source] = call
f.mu.Unlock()
outcome(ctx, "downloaded")
call.path, call.err = f.fetch(ctx, source, target)
call.path, call.err = fetch()
close(call.done)
f.mu.Lock()
@@ -190,9 +220,83 @@ func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool) (
// fetch downloads a source to a target, resuming and verifying.
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.
//
// The two differ for a set: the shards waiting on it asked for the version, not
@@ -201,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
// 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",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(attribute.String("llmgw.weights.source", redact(source))))
@@ -265,8 +369,8 @@ func (f *Fetcher) fetchAs(ctx context.Context, source, reportAs, target string)
}
total := existing + resp.ContentLength
f.report(Progress{Source: reportAs, BytesDone: existing, BytesTotal: total})
written, err := f.copyWithProgress(ctx, out, resp.Body, reportAs, existing, total)
f.report(share.of(Progress{Source: reportAs, BytesDone: existing, BytesTotal: total}))
written, err := f.copyWithProgress(ctx, out, resp.Body, reportAs, existing, total, share)
span.SetAttributes(
attribute.Int64("llmgw.weights.resumed_from", existing),
attribute.Int64("llmgw.weights.bytes", written),
@@ -290,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,
source string, done, total int64,
source string, done, total int64, share setShare,
) (int64, error) {
buf := make([]byte, 4<<20)
last := time.Now()
@@ -308,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
// needs to be told that often.
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()
}
}
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
}
if err != nil {
@@ -322,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) {
if f.OnProgress != nil {
f.OnProgress(p)
+74
View File
@@ -1,6 +1,8 @@
package weights
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/http/httptest"
@@ -287,3 +289,75 @@ func TestAnAbsolutePathThatIsMissingIsStillAnError(t *testing.T) {
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)
}
}
+113 -15
View File
@@ -5,12 +5,16 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// IsSet reports whether a source names a set of files rather than one.
@@ -89,6 +93,12 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string, allowPickle bool
if err := os.MkdirAll(dir, 0o755); err != nil {
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 {
// Rejected rather than joined: a name with a separator in it would
// write outside the directory the mirror said it was in, and the
@@ -98,34 +108,24 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string, allowPickle bool
}
target := filepath.Join(dir, file.Name)
if st, err := os.Stat(target); err == nil && st.Size() == file.Bytes {
before += file.Bytes
continue
}
// Reported under the set's own address, because that is what the
// shards are waiting on. Reusing fetch rather than writing a second
// downloader keeps resume, the .part rename and the progress callback
// 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
}
before += file.Bytes
}
// 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
// a model that answers nonsense, not as an error.
for _, file := range m.Files {
if file.SHA256 == "" {
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 := f.checkSet(ctx, source, dir, m); err != nil {
return "", err
}
if err := os.WriteFile(done, []byte(source+"\n"), 0o644); err != nil {
@@ -187,3 +187,101 @@ func fileSHA256(path string) (string, error) {
}
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
}
}
}
+103
View File
@@ -9,7 +9,10 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
)
// mirrorOf serves a manifest at "/m/v/" and the files beneath it.
@@ -204,3 +207,103 @@ func TestASetStillDownloadingOnTheMirrorSaysSo(t *testing.T) {
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)
}
}
+3 -2
View File
@@ -112,8 +112,9 @@ func (w *statusWriter) Flush() {
}
// notChanges are POSTs that change nothing and carry text that must not be
// kept: asking a judge about a sentence.
var notChanges = []string{"/api/v1/guard/judges/ask"}
// kept: asking a judge about a sentence. And a standby's report of what its
// 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
// HEAD, under /api/. actor says who made it.
+35 -17
View File
@@ -15,6 +15,7 @@ import (
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
"golang.org/x/sync/singleflight"
)
// 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
// client address.
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 {
@@ -166,26 +173,37 @@ func (a *Authenticator) AuthenticateFrom(ctx context.Context, presented, client
return e.principal, e.err
}
if client != "" && !a.lookups.allow(client, a.now()) {
return Principal{}, ErrThrottled
}
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.
// One read for every request missing this key at once, rationed once.
v, err, _ := a.resolving.Do(id, func() (any, error) {
if e, ok := a.lookup(id); ok { // filled while this waited its turn
return e, nil
}
if client != "" && !a.lookups.allow(client, a.now()) {
return nil, ErrThrottled
}
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
}
ttl := a.cfg.TTL
if err != nil {
ttl = a.cfg.NegativeTTL
e := v.(entry)
if e.err == nil {
a.markUsed(e.principal.KeyID)
}
a.store_(id, entry{principal: p, err: err, expires: a.now().Add(ttl)})
if err == nil {
a.markUsed(p.KeyID)
}
return p, err
return e.principal, e.err
}
func (a *Authenticator) resolve(ctx context.Context, hash []byte) (Principal, error) {
+38
View File
@@ -478,3 +478,41 @@ func TestTheCacheDoesNotGrowWithoutBound(t *testing.T) {
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)
}
+8 -2
View File
@@ -321,6 +321,8 @@ func locationColumn(n adminapi.NodeView) string {
// actually served, never what is merely catalogued.
func modelStatus(m adminapi.ModelView) string {
switch {
case m.Stopped:
return "stopped (llmgw ctl model start)"
case m.Parked:
return "parked"
case m.External != "":
@@ -497,8 +499,12 @@ func newReplicasCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
if s.Fetch == nil {
continue
}
fmt.Fprintf(w, " %s: fetching %s\t\t\t\t\t\t\t\t\n",
s.Node, fetchColumn(s.Fetch))
what := "fetching"
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()
+98 -19
View File
@@ -11,11 +11,13 @@ import (
"runtime"
"slices"
"strings"
"sync"
"time"
"github.com/spf13/cobra"
"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/release"
"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) })
var todo []adminapi.NodeView
var away []string
skipped := 0
for _, n := range nodes {
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++
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)
}
if len(away) > 0 {
fmt.Fprintf(out, "Passed over, not connected: %s\n", strings.Join(away, ", "))
}
parallel = max(parallel, 1)
start, size, done := time.Now(), 1, 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
// 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,
wave []adminapi.NodeView, perNode time.Duration,
) 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))
body, _ := json.Marshal(adminapi.UpgradeRequest{Version: v})
var failed []string
var (
mu sync.Mutex
failed []string
wg sync.WaitGroup
)
for _, n := range wave {
// The control plane waits for the agent to fetch and check the
// release, up to five minutes; fifteen seconds gave up on a node
// that was upgrading.
if err := client().patient(6*time.Minute).post(ctx, "/api/v1/nodes/"+n.ID+"/upgrade", body, nil); err != nil {
failed = append(failed, fmt.Sprintf("%s: %v", n.ID, err))
}
wg.Add(1)
go func() {
defer wg.Done()
// The control plane waits for the agent's answer, or for it to
// 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 {
return fmt.Errorf("not told to upgrade:\n %s\n`llmgw ctl logs NODE` says more", strings.Join(failed, "\n "))
}
waiting := slices.Clone(ids)
wg.Wait()
waiting := slices.DeleteFunc(slices.Clone(ids), func(id string) bool {
return slices.ContainsFunc(failed, func(f string) bool { return strings.HasPrefix(f, id+": ") })
})
deadline := time.Now().Add(perNode)
for {
backAt := map[string]time.Time{}
said := time.Now()
for len(waiting) > 0 {
var now []adminapi.NodeView
_ = 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{}
for _, x := range now {
state[x.ID] = x
}
waiting = slices.DeleteFunc(waiting, func(id string) bool {
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 {
fmt.Fprintf(out, " done in %s\n", time.Since(start).Round(time.Second))
return nil
break
}
if time.Now().After(deadline) {
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)",
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 "+
"previous binary beside the new one", perNode, strings.Join(why, "\n "))
failed = append(failed, fmt.Sprintf("not back within %s: %s", perNode, strings.Join(why, ", ")))
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)
}
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.
+28 -1
View File
@@ -6,6 +6,7 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
@@ -21,6 +22,8 @@ type fakeFleet struct {
versions map[string]string
stuck map[string]bool
told []string
// states are nodes' states other than ready.
states map[string]string
}
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":
var out []adminapi.NodeView
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)
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())
}
}
// 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)
}
}
+2
View File
@@ -72,6 +72,8 @@ func newEngineCheckCmd() *cobra.Command {
cfg.VLLM.Bin = bin
case "sglang":
cfg.SGLang.Python = bin
case "freetoken":
cfg.FreeToken.Binary = bin
case "trtllm":
cfg.TRTLLM.Python = bin
case "sim":
+18 -3
View File
@@ -49,6 +49,9 @@ type leaderForward struct {
self string
tls tlsconf.Config
log *slog.Logger
// routes, with router.active_active, is the leader's replicas this
// standby routes to; nil when every request goes to the leader.
routes *standbyRoutes
}
// standby wraps the admin handler: /api/ goes to the leader while this
@@ -73,8 +76,10 @@ func (f *leaderForward) standby(next http.Handler) http.Handler {
p := &httputil.ReverseProxy{
Rewrite: func(pr *httputil.ProxyRequest) {
pr.Out.URL.Scheme, pr.Out.URL.Host = "https", target.Host
// Escaped as it came: a name with a slash in it, Demo/hr
// as Demo%2Fhr, is one segment, not two.
pr.Out.URL.Path = forwardPrefix + pr.In.URL.Path
pr.Out.URL.RawPath = ""
pr.Out.URL.RawPath = forwardPrefix + pr.In.URL.EscapedPath()
pr.Out.Host = target.Host
// The leader's own token is not this one's, and the
// certificate is what it believes.
@@ -115,7 +120,8 @@ func (f *leaderForward) public(next http.Handler, port string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Once only: a request another control plane forwarded is answered
// here, leading or not, rather than sent on while the lease moves.
if publicStaysHere(r.URL.Path) || f.lead.IsLeader() || r.Header.Get(hdrForwardedBy) != "" {
if publicStaysHere(r.URL.Path) || f.lead.IsLeader() || r.Header.Get(hdrForwardedBy) != "" ||
f.answersHere(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
@@ -152,6 +158,15 @@ func (f *leaderForward) public(next http.Handler, port string) http.Handler {
})
}
// answersHere is whether a standby answers this client request itself: the
// API under /v1, with router.active_active, while it has the leader's
// replicas to send it to. Not the portal, its admin pages or /mcp, whose
// llmgw:admin tools read the cluster from memory only the leader has; and
// not while the leader has gone unheard, when it is passed on as before.
func (f *leaderForward) answersHere(path string) bool {
return f.routes != nil && strings.HasPrefix(path, "/v1/") && f.routes.serving()
}
// trustControlPlanes keeps the client port trusting the other control
// planes' X-Forwarded-For, besides router.trusted_proxies: a standby passes
// requests on, and the client is the address it was given, not the standby.
@@ -221,7 +236,7 @@ func (f *leaderForward) leaderSide(admin http.Handler) http.Handler {
return
}
r2 := r.Clone(r.Context())
r2.URL.Path, r2.URL.RawPath = path, ""
r2.URL.Path, r2.URL.RawPath = path, strings.TrimPrefix(r.URL.EscapedPath(), forwardPrefix)
w.Header().Set(hdrAnsweredBy, f.self)
admin.ServeHTTP(w, r2)
})
+19 -2
View File
@@ -32,7 +32,11 @@ type forwardCluster struct {
leaderURL string
}
func newForwardCluster(t *testing.T) *forwardCluster {
func newForwardCluster(t *testing.T) *forwardCluster { return newForwardClusterWith(t, nil) }
// newForwardClusterWith answers forwarded requests on the leader with admin,
// or, given nil, with one that says what it was asked.
func newForwardClusterWith(t *testing.T, leaderAdmin http.Handler) *forwardCluster {
t.Helper()
dir := t.TempDir()
ca, err := tlsconf.NewCA("test CA")
@@ -77,10 +81,13 @@ func newForwardCluster(t *testing.T) *forwardCluster {
// The leader's admin API: says whose it is, and who it was asked by.
admin := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path, "by": r.Header.Get(hdrForwardedBy),
_ = json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path, "raw": r.URL.EscapedPath(), "by": r.Header.Get(hdrForwardedBy),
"auth": r.Header.Get("Authorization"), "actor": r.Header.Get("X-Llmgw-Actor")})
})
mux := http.NewServeMux()
if leaderAdmin != nil {
admin = leaderAdmin.ServeHTTP
}
mux.Handle(forwardPrefix+"/", c.leader.leaderSide(admin))
srv := httptest.NewUnstartedServer(mux)
tc, err := cfgFor("ctl-a").ServerConfig()
@@ -242,3 +249,13 @@ func TestAStandbysPortalIsTheLeaders(t *testing.T) {
t.Errorf("a forwarded request was forwarded again: %q", body)
}
}
// A name with a slash in it, escaped, stays one segment on the leader: a
// collection Demo/hr removed through a standby was a 405 there.
func TestAnEscapedSlashReachesTheLeaderEscaped(t *testing.T) {
c := newForwardCluster(t)
resp, got := c.ask(t, "/api/v1/kb/collections/Demo%2Fhr")
if resp.StatusCode != http.StatusOK || got["raw"] != "/api/v1/kb/collections/Demo%2Fhr" {
t.Fatalf("%d %v", resp.StatusCode, got)
}
}
+32 -2
View File
@@ -16,6 +16,7 @@ import (
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
"gitea.mynodes.xyz/ilker/llmgw/internal/tlsconf"
)
@@ -57,7 +58,7 @@ func newMirrorCmd() *cobra.Command {
}
func newMirrorAddCmd(dir *string) *cobra.Command {
var model, version, as string
var model, version, as, sha, registry string
c := &cobra.Command{
Use: "add <url>...",
@@ -79,7 +80,7 @@ func newMirrorAddCmd(dir *string) *cobra.Command {
if model == "" || version == "" {
return fmt.Errorf("--model and --version are required: they are the layout")
}
if as != "" && len(args) > 1 {
if (as != "" || sha != "") && len(args) > 1 {
return fmt.Errorf("--as names one file; with %d URLs each keeps its own name", len(args))
}
out := cmd.OutOrStdout()
@@ -90,6 +91,21 @@ func newMirrorAddCmd(dir *string) *cobra.Command {
file = mirror.LastPathElement(source)
}
e := mirror.Entry{Model: model, Version: version, File: file}
// From the registry: what it published for the file, to
// compare with what arrives.
want := strings.ToLower(sha)
if id, rev, path, ok := hf.FileSource(source, registry); ok && want == "" {
repo, err := hf.New(registry, 30*time.Second).RepoAt(cmd.Context(), id, rev)
if err != nil {
return fmt.Errorf("looking up %s in the registry, for the sha256 it published: %w "+
"(--sha256 gives it instead)", id, err)
}
for _, f := range repo.Files {
if f.Path == path {
want = f.SHA256
}
}
}
fmt.Fprintf(out, "fetching %s\n", source)
added, err := mirror.Add(cmd.Context(), *dir, source, e, func(done, total int64) {
if total > 0 {
@@ -102,6 +118,14 @@ func newMirrorAddCmd(dir *string) *cobra.Command {
if err != nil {
return err
}
if want != "" && added.SHA256 != want {
_ = mirror.Remove(*dir, e)
return fmt.Errorf("%s arrived with sha256 %s, and %s was published for it: deleted",
source, added.SHA256, want)
}
if want != "" {
fmt.Fprintln(out, " sha256 matches the one published")
}
fmt.Fprintf(out, "%s/%s/%s %s\n sha256 %s\n",
added.Entry.Model, added.Entry.Version, added.Entry.File,
fileSize(added.Entry.Bytes), added.SHA256)
@@ -123,6 +147,8 @@ func newMirrorAddCmd(dir *string) *cobra.Command {
c.Flags().StringVar(&model, "model", "", "the model's name, one directory level")
c.Flags().StringVar(&version, "version", "", "the version, one directory level; a date works well")
c.Flags().StringVar(&as, "as", "", "the filename to save as, for one URL; taken from the URL when omitted")
c.Flags().StringVar(&sha, "sha256", "", "the sha256 the file must have, for one URL; looked up for a Hugging Face URL")
c.Flags().StringVar(&registry, "registry", hf.DefaultEndpoint, "the registry a resolve URL is looked up in")
return c
}
@@ -238,6 +264,10 @@ func newMirrorRemoveCmd(dir *string) *cobra.Command {
if err := mirror.Remove(*dir, e); err != nil {
return err
}
// Kept for a mirror kept in step, which otherwise copies it back.
if err := mirror.RecordRemoval(*dir, model, version); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "removed %s/%s/%s\n", model, version, file)
return nil
},
+15
View File
@@ -34,6 +34,8 @@ step with this one: each copies what the other holds, checksum checked, and
a model removed from one is removed from the other. This mirror is told of
it, and the cluster's settings name it (mirror.peers), so while either
machine is down the control planes use the other and nodes fetch from it.
On a mirror machine without the control plane's settings file, it prints
the llmgw ctl config set to run on a control plane instead.
HOST needs room for every model this mirror holds: it copies them all.`,
Example: " sudo llmgw mirror add-peer ubuntu@10.0.0.12 --control-plane 10.0.0.10 --control-plane 10.0.0.12",
@@ -123,6 +125,19 @@ func addMirrorPeer(ctx context.Context, out io.Writer, remote onboard.Remote, ho
fmt.Fprintf(out, " ok this mirror keeps in step with %s\n", peerAPI)
// The control planes told of both.
return nameMirrorPeer(out, configPath, peerAPI)
}
// nameMirrorPeer names a peer in the cluster's settings. A mirror on a
// machine of its own has neither the settings file nor the store: it says
// what to run on a control plane.
func nameMirrorPeer(out io.Writer, configPath, peerAPI string) error {
if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) {
fmt.Fprintf(out, " !! this machine has no %s, so the cluster's settings are not changed here. On a control plane:\n"+
" sudo llmgw ctl config set mirror.peers '[%s]'\n"+
" (with any peers already named, which llmgw ctl config get mirror.peers shows)\n", configPath, peerAPI)
return nil
}
if err := addMirrorPeerSetting(configPath, peerAPI); err != nil {
return err
}
+11
View File
@@ -1,6 +1,7 @@
package cli
import (
"bytes"
"os"
"path/filepath"
"strings"
@@ -72,3 +73,13 @@ func TestTheSetupOffersASecondMirrorOnAStoreMachine(t *testing.T) {
t.Errorf("without standbys: %v", err)
}
}
func TestAMirrorOnAMachineOfItsOwnSaysWhatToRunOnAControlPlane(t *testing.T) {
var out bytes.Buffer
if err := nameMirrorPeer(&out, filepath.Join(t.TempDir(), "server.yaml"), "http://10.0.0.12:8082"); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "llmgw ctl config set mirror.peers '[http://10.0.0.12:8082]'") {
t.Errorf("%s", out.String())
}
}
+119 -17
View File
@@ -34,7 +34,7 @@ func newModelCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
Use: "model",
Short: "Add or remove models while the cluster is running",
}
c.AddCommand(newModelContextCmd(client), newModelGPUsCmd(client), newModelAddCmd(client), newModelPresetsCmd(client, asJSON), newModelRemoveCmd(client), newModelPlanCmd(client),
c.AddCommand(newModelStopCmd(client), newModelStartCmd(client), newModelContextCmd(client), newModelGPUsCmd(client), newModelAddCmd(client), newModelPresetsCmd(client, asJSON), newModelRemoveCmd(client), newModelPlanCmd(client),
newResourcesCmd(client), newClassCmd(client), newOwnerCmd(client),
newTargetCmd(client, asJSON), newAdaptersCmd(client),
newModelServeCmd(client), newModelUpdatesCmd(client, asJSON), newModelUpdateCmd(client),
@@ -163,21 +163,104 @@ func readModelFile(path string) (catalog.Model, error) {
}
func newModelRemoveCmd(client func() *ctlClient) *cobra.Command {
return &cobra.Command{
var purge bool
c := &cobra.Command{
Use: "remove <name>",
Short: "Remove a model that was added at runtime",
Short: "Remove a model that was added at runtime, and with --purge its files",
Long: "Only models added at runtime. One declared in server.yaml would come\n" +
"back on the next restart, so removing it here would leave the cluster\n" +
"disagreeing with its own configuration until then.\n\n" +
"Its replicas are stopped on the next pass, after the endpoint has been\n" +
"withdrawn, so traffic stops before the engine does.",
"withdrawn, so traffic stops before the engine does.\n\n" +
"Its files stay on the mirror and the nodes, for adding it again. With\n" +
"--purge they go too: every version of it on the mirror (a mirror kept in\n" +
"step follows), and the nodes' copies once its replicas have stopped.\n" +
"What another model uses stays. To take a model out of service and keep\n" +
"it, use llmgw ctl model stop.",
Example: " llmgw ctl model remove qwen-7b\n" +
" llmgw ctl model remove qwen-7b --purge # its files on the mirror and the nodes too",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var res map[string]string
if err := client().delete(cmd.Context(), "/api/v1/models/"+args[0], &res); err != nil {
out := cmd.OutOrStdout()
if !purge {
var res map[string]string
if err := client().delete(cmd.Context(), "/api/v1/models/"+args[0], &res); err != nil {
return err
}
fmt.Fprintf(out, "%s removed. Its files stay on the mirror and the nodes; --purge deletes them.\n", args[0])
return nil
}
var v adminapi.PurgeView
if err := client().patient(3*time.Minute).delete(cmd.Context(), "/api/v1/models/"+args[0]+"?purge=true", &v); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s removed.\n", args[0])
fmt.Fprintf(out, "%s removed.\n", v.Removed)
for _, m := range v.Mirror {
fmt.Fprintf(out, " mirror %s deleted\n", m)
}
for _, m := range v.Kept {
fmt.Fprintf(out, " mirror %s kept: another model uses it\n", m)
}
var freed int64
for _, n := range v.Nodes {
if n.Error != "" {
fmt.Fprintf(out, " node %s: %s\n", n.Node, n.Error)
continue
}
freed += n.Bytes
fmt.Fprintf(out, " node %s: %d file(s), %s\n", n.Node, n.Files, humanBytes(n.Bytes))
}
if len(v.Mirror) == 0 && len(v.Nodes) == 0 && v.Note == "" {
fmt.Fprintln(out, " it had no files of its own on the mirror or the nodes")
}
if freed > 0 {
fmt.Fprintf(out, " %s freed on the nodes\n", humanBytes(freed))
}
if v.Note != "" {
fmt.Fprintln(out, " "+v.Note)
}
return nil
},
}
c.Flags().BoolVar(&purge, "purge", false, "delete its files from the mirror and the nodes as well")
return c
}
func newModelStopCmd(client func() *ctlClient) *cobra.Command {
return &cobra.Command{
Use: "stop <model>",
Short: "Take a model out of service and keep it: none of its replicas run until it is started",
Long: "Its replicas stop; it stays in the catalog with its files, and a request\n" +
"for it is told an administrator stopped it. What it was scaled to is kept:\n" +
"llmgw ctl model start runs it again as it was.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var v adminapi.ScaleResponse
if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/stop", nil, &v); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s stopped: its replicas stop on the next pass. It is kept; "+
"llmgw ctl model start %s runs it again.\n", v.Model, v.Model)
return nil
},
}
}
func newModelStartCmd(client func() *ctlClient) *cobra.Command {
return &cobra.Command{
Use: "start <model>",
Short: "Run a stopped model again, scaled as it was when it was stopped",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var v adminapi.ScaleResponse
if err := client().post(cmd.Context(), "/api/v1/models/"+args[0]+"/start", nil, &v); err != nil {
return err
}
how := fmt.Sprintf("%d replica(s)", v.Min)
if v.OnDemand {
how = "on demand"
}
fmt.Fprintf(cmd.OutOrStdout(), "%s started: %s, max %d. Watch it with llmgw ctl models.\n", v.Model, how, v.Max)
return nil
},
}
@@ -389,10 +472,10 @@ func newAdaptersCmd(client func() *ctlClient) *cobra.Command {
}
func newModelContextCmd(client func() *ctlClient) *cobra.Command {
var slots int
var slots, maxOut int
c := &cobra.Command{
Use: "context <model> <tokens>",
Short: "Change how much one request to a served model may use",
Use: "context <model> [tokens]",
Short: "Change how much one request to a served model may use, and how long its answers may be",
Long: "The context lives in three places: the catalog (what a request may use, and\n" +
"what it may answer), the engine's arguments (llama.cpp's --ctx-size and\n" +
"--parallel, vLLM's --max-model-len), and the memory the KV cache takes. This\n" +
@@ -400,21 +483,39 @@ func newModelContextCmd(client func() *ctlClient) *cobra.Command {
"and a context its cards would not hold, saying what each would need. Its\n" +
"replicas are then started again on it, each new one before the old stops.\n\n" +
"--slots is how many requests a llama.cpp replica answers at once; its pool is\n" +
"slots × tokens. Left out, it keeps what it has.",
"slots × tokens. Left out, it keeps what it has.\n\n" +
"--max-output is the longest answer the gateway lets a request ask for. With a\n" +
"context it is set with it; left out, it follows the context as it did. Alone,\n" +
"without tokens, it changes only that, and no replica is started again.\n\n" +
"Strata's context is its setup's --context, and FreeToken's\n" +
"--max-seq-len-override; for both the card is not checked, since each sizes its\n" +
"caches to it.",
Example: " llmgw ctl model context qwen3-coder-30b 65536\n" +
" llmgw ctl model context qwen2.5-7b 16384 --slots 4",
Args: cobra.ExactArgs(2),
" llmgw ctl model context qwen2.5-7b 16384 --slots 4\n" +
" llmgw ctl model context qwen3.8-flash-next-iq2_xs 32768 --max-output 8192\n" +
" llmgw ctl model context qwen3.8-flash-next-iq2_xs --max-output 4096",
Args: cobra.RangeArgs(1, 2),
RunE: func(cmd *cobra.Command, args []string) error {
tokens, err := strconv.Atoi(args[1])
if err != nil {
return fmt.Errorf("the context is a whole number of tokens, not %q", args[1])
tokens := 0
if len(args) == 2 {
var err error
if tokens, err = strconv.Atoi(args[1]); err != nil {
return fmt.Errorf("the context is a whole number of tokens, not %q", args[1])
}
} else if maxOut == 0 {
return fmt.Errorf("say the context's tokens, or --max-output, or both")
}
body, _ := json.Marshal(modelserve.ContextRequest{Tokens: tokens, Slots: slots})
body, _ := json.Marshal(modelserve.ContextRequest{Tokens: tokens, Slots: slots, MaxOutput: maxOut})
var v modelserve.ContextChange
if err := client().patient(time.Minute).post(cmd.Context(), "/api/v1/models/"+args[0]+"/context", body, &v); err != nil {
return err
}
out := cmd.OutOrStdout()
if v.OutputOnly {
fmt.Fprintf(out, "%s: answers up to %d tokens (was %d); its context stays %d, and no replica was started again\n",
v.Model, v.MaxOutput, v.MaxOutputBefore, v.After)
return nil
}
fmt.Fprintf(out, "%s: %d → %d tokens a request, answers up to %d", v.Model, v.Before, v.After, v.MaxOutput)
if v.Trained > 0 {
fmt.Fprintf(out, " (trained for %d)", v.Trained)
@@ -440,6 +541,7 @@ func newModelContextCmd(client func() *ctlClient) *cobra.Command {
},
}
c.Flags().IntVar(&slots, "slots", 0, "llama.cpp: requests a replica answers at once (default: as it is)")
c.Flags().IntVar(&maxOut, "max-output", 0, "the longest answer, in tokens (default: follows the context)")
return c
}
+2
View File
@@ -64,6 +64,8 @@ func newModelServeCmd(client func() *ctlClient) *cobra.Command {
"--embedding, 32 slots of 1024 tokens unless --ctx-pool or --context-length says otherwise")
f.StringVar(&req.Format, "format", "gguf", "gguf, run by llama.cpp, or safetensors: the original weights, run by vLLM on GPUs")
f.BoolVar(&req.CPU, "cpu", false, "run it on the CPU, whatever the plan says")
f.StringVar(&req.Engine, "engine", "", "strata, for a family Strata runs: one card and the experts in RAM; "+
"llamacpp to keep it off Strata (default: the plan's best)")
f.StringVar(&req.GPUClass, "gpu-class", "", "run it on GPUs of this class, whatever the plan says")
f.IntVar(&req.GPUsPerNode, "gpus", 0, "with --gpu-class: the cards one replica takes on each node (default 1)")
f.IntVar(&req.Nodes, "nodes", 0, "with --gpu-class: the nodes one replica spans, over a fabric (vLLM only)")
+7
View File
@@ -310,6 +310,9 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
if b.CPU {
holds, all = fmt.Sprintf("%.1f GB of RAM", b.NeedGB), "-"
}
if b.Engine == "strata" {
holds, all = fmt.Sprintf("%.0f GB of RAM", b.NeedGB), "1 card"
}
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%.0f GB\t%s\t%s\n", name, bytesShort(a.Bytes), b.Label(), holds, b.HaveGB, all, now)
}
_ = w.Flush()
@@ -339,6 +342,10 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
if p.Format == "safetensors" {
fmt.Fprint(out, " --format safetensors")
}
if rec.Engine == "strata" {
fmt.Fprintf(out, " --file %s --engine strata\n", rec.Artifact)
return nil
}
fmt.Fprintln(out, " --fallbacks")
return nil
}
+22
View File
@@ -453,6 +453,9 @@ func newServerCmd() *cobra.Command {
if st := rt.Store(); st != nil {
svc := modelsvc.New(cfg.Models, st, admin, log)
svc.SetCheck(servableCheck(cfg.WantedEngines()["llamacpp"]))
// What is added from the registry is pinned to the
// commit it is at, with each file's sha256 for the nodes.
svc.SetRegistry(hf.New(cfg.Mirror.Registry, 30*time.Second))
// Other llmgw clusters' models, offered as PEER/MODEL:
// read before the catalog is built, so a model that falls
// back to one is whole at startup.
@@ -583,6 +586,10 @@ func newServerCmd() *cobra.Command {
agentsvc.SetSourceRewrite(src.Rewrite)
}
rt.SetMirror(mc)
if adminAPI != nil {
// A model removed with its files is removed from it too.
adminAPI.SetPurgeMirror(mc)
}
if fwPub != nil {
fwPub.setMirror(mc)
}
@@ -684,6 +691,21 @@ func newServerCmd() *cobra.Command {
if admin != nil && admin.Leadership() != nil && rt.Store() != nil && cfg.AgentTLS.Enabled() {
self, _ := os.Hostname()
forward = &leaderForward{st: rt.Store(), lead: admin.Leadership(), self: self, tls: cfg.AgentTLS, log: log}
// And with router.active_active, a standby answers the client
// API itself, to the leader's replicas.
if cfg.Router.ActiveActive {
var refresh func(context.Context) error
if catalogSvc != nil {
refresh = catalogSvc.Refresh
}
routes := newStandbyRoutes(forward, rt, refresh, admin.Publish, log)
forward.routes = routes
admin.SetReplicaFeed(routes)
rt.SetActivator(standbyActivator{Reconciler: admin.Reconciler(), routes: routes})
go routes.run(ctx)
go rt.ShareBudgets(ctx)
log.Info("standbys answer the client API themselves (router.active_active)")
}
}
served := adminHandler
if forward != nil {
+3
View File
@@ -64,6 +64,9 @@ const agentUnit = `[Unit]
Description=llmgw node agent
After=network-online.target
Wants=network-online.target
# Never given up on: a new release that fails its first starts, or an upgrade
# that restarts it twice in a minute, would otherwise leave the node off.
StartLimitIntervalSec=0
[Service]
ExecStart=/usr/local/bin/llmgw agent --config /etc/llmgw/agent.yaml
+254
View File
@@ -0,0 +1,254 @@
package cli
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
"golang.org/x/net/http2"
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/reconciler"
"gitea.mynodes.xyz/ilker/llmgw/internal/router"
)
// How often a standby answering requests itself hears from the leader, and
// how long it keeps routing to the replicas it last heard of when it does
// not. Long enough to ride out the leader changing hands — the engines are
// still where they were, and a dead one opens its breaker — short enough
// that a standby cut off from the cluster stops sending to a list it cannot
// check.
const (
standbyPoll = 2 * time.Second
standbyTrust = time.Minute
)
// The router is what counts against limits, and shares the counts.
var _ control.RateSharer = (*router.Router)(nil)
// standbyRoutes is a standby's copy of the leader's Ready replicas, when
// standbys answer requests themselves (router.active_active). Each tick it
// reports what its router used and is answered with the replicas, in one
// request over the agent port with its control-plane certificate.
type standbyRoutes struct {
fwd *leaderForward
usage control.UsageSource
refresh func(context.Context) error // reads the catalog again from the store
publish func(context.Context) // hands the router the new list now
log *slog.Logger
now func() time.Time
mu sync.Mutex
reps []control.RoutedReplica
heard time.Time
catalog string // the leader's catalog revision last seen
sent map[string]time.Time // the last use already reported, by replica
want map[string]bool // on-demand models asked for since the last report
kick chan struct{}
// The connection to the leader, kept between ticks: one per leader
// address, the previous one's closed when the lease moves.
client *http.Client
clientHost string
}
func newStandbyRoutes(fwd *leaderForward, usage control.UsageSource, refresh func(context.Context) error,
publish func(context.Context), log *slog.Logger,
) *standbyRoutes {
return &standbyRoutes{fwd: fwd, usage: usage, refresh: refresh, publish: publish, log: log, now: time.Now,
sent: map[string]time.Time{}, want: map[string]bool{}, kick: make(chan struct{}, 1)}
}
// Ready satisfies control.ReplicaFeed.
func (s *standbyRoutes) Ready() ([]control.RoutedReplica, bool) {
s.mu.Lock()
defer s.mu.Unlock()
if s.heard.IsZero() || s.now().Sub(s.heard) > standbyTrust {
return nil, false
}
return s.reps, true
}
// serving says whether this standby has replicas to send a request to, which
// is when it keeps the client API rather than passing it to the leader.
func (s *standbyRoutes) serving() bool {
reps, ok := s.Ready()
return ok && len(reps) > 0
}
// wanted queues an on-demand model for the leader, and reports at once
// rather than on the next tick: someone is waiting on it.
func (s *standbyRoutes) wanted(model string) {
s.mu.Lock()
s.want[model] = true
s.mu.Unlock()
select {
case s.kick <- struct{}{}:
default:
}
}
func (s *standbyRoutes) run(ctx context.Context) {
t := time.NewTicker(standbyPoll)
defer t.Stop()
for {
if s.fwd.lead.IsLeader() {
// Its own reconciler's replicas now. What it heard as a standby
// is forgotten, so becoming one again starts afresh.
s.mu.Lock()
s.reps, s.heard, s.catalog = nil, time.Time{}, ""
clear(s.sent)
clear(s.want)
s.mu.Unlock()
} else if err := s.exchange(ctx); err != nil {
s.log.Debug("could not hear from the leader", "err", err)
}
select {
case <-ctx.Done():
return
case <-t.C:
case <-s.kick:
}
}
}
// exchange reports activity and takes the replicas in return.
func (s *standbyRoutes) exchange(ctx context.Context) error {
act, sent := s.activity()
body, err := json.Marshal(act)
if err != nil {
return err
}
target, name, err := s.fwd.leader(ctx)
if err != nil {
return err
}
client, err := s.clientFor(target.Host, target.Hostname())
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
"https://"+target.Host+forwardPrefix+"/api/v1/routing/activity", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set(hdrForwardedBy, s.fwd.self)
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("%s answered %s: %s", name, resp.Status, bytes.TrimSpace(b))
}
var rt control.Routing
if err := json.NewDecoder(resp.Body).Decode(&rt); err != nil {
return fmt.Errorf("%s's routing: %w", name, err)
}
s.mu.Lock()
s.reps, s.heard = rt.Replicas, s.now()
stale := rt.Catalog != s.catalog
s.catalog = rt.Catalog
for id, at := range sent {
s.sent[id] = at
}
for _, m := range act.Want {
delete(s.want, m)
}
s.mu.Unlock()
// What the leader and the other standbys counted, so this router's
// limits are the cluster's.
if rs, ok := s.usage.(control.RateSharer); ok && rt.Rates != nil {
rs.SetOtherRates(*rt.Rates)
}
// A model added or changed through the leader is in the store: the
// catalog first, so the replicas published next resolve against it.
if stale && s.refresh != nil {
if err := s.refresh(ctx); err != nil {
s.log.Warn("could not read the catalog the leader changed", "err", err)
s.mu.Lock()
s.catalog = "" // tried again next tick
s.mu.Unlock()
}
}
if s.publish != nil {
s.publish(ctx)
}
return nil
}
// clientFor is the client for the leader at host, made once per leader.
// Only the run loop calls it.
func (s *standbyRoutes) clientFor(host, name string) (*http.Client, error) {
if s.client != nil && s.clientHost == host {
return s.client, nil
}
tc, err := s.fwd.tls.ClientConfig(name)
if err != nil {
return nil, err
}
if s.client != nil {
s.client.CloseIdleConnections()
}
s.client = &http.Client{Transport: &http2.Transport{TLSClientConfig: tc, ReadIdleTimeout: 30 * time.Second}}
s.clientHost = host
return s.client, nil
}
// activity is what to report: uses newer than the last reported, and the
// models asked for. sent is what to record as reported once the leader has
// it.
func (s *standbyRoutes) activity() (control.Activity, map[string]time.Time) {
var used map[string]time.Time
if s.usage != nil {
used = s.usage.LastUsed()
}
s.mu.Lock()
defer s.mu.Unlock()
act := control.Activity{From: s.fwd.self, LastUsed: map[string]time.Time{}}
if rs, ok := s.usage.(control.RateSharer); ok {
r := rs.Rates()
act.Rates = &r
}
for id, at := range used {
if at.After(s.sent[id]) {
act.LastUsed[id] = at
}
}
for m := range s.want {
act.Want = append(act.Want, m)
}
return act, act.LastUsed
}
// standbyActivator asks the leader for an on-demand model when a request for
// it reaches a standby: the standby's own reconciler places nothing. Which
// models load on demand, and why one cannot, are still the reconciler's to
// answer from the catalog.
type standbyActivator struct {
*reconciler.Reconciler
routes *standbyRoutes
}
func (a standbyActivator) Want(model string) bool {
if !a.Reconciler.Want(model) {
return false
}
if !a.routes.fwd.lead.IsLeader() {
a.routes.wanted(model)
}
return true
}
+186
View File
@@ -0,0 +1,186 @@
package cli
import (
"context"
"encoding/json"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/accounting"
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
)
// fakeLeaderRouting is the leader's routing API: what it was told, and the
// replicas and catalog it answers with.
type fakeLeaderRouting struct {
mu sync.Mutex
got []control.Activity
routing control.Routing
}
func (f *fakeLeaderRouting) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/api/v1/routing/activity" {
http.NotFound(w, r)
return
}
var act control.Activity
if err := json.NewDecoder(r.Body).Decode(&act); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
f.mu.Lock()
defer f.mu.Unlock()
f.got = append(f.got, act)
_ = json.NewEncoder(w).Encode(f.routing)
}
func (f *fakeLeaderRouting) last() control.Activity {
f.mu.Lock()
defer f.mu.Unlock()
return f.got[len(f.got)-1]
}
type fixedUsage map[string]time.Time
func (u fixedUsage) LastUsed() map[string]time.Time { return u }
// sharingUsage is a router whose limits are counted with the others'.
type sharingUsage struct {
fixedUsage
own accounting.Rates
others *accounting.Rates
}
func (u *sharingUsage) Rates() accounting.Rates { return u.own }
func (u *sharingUsage) SetOtherRates(r accounting.Rates) { u.others = &r }
// A standby reports its counts under its name and puts what the leader says
// the rest of the cluster counted in force on its router.
func TestAStandbysLimitsAreTheClusters(t *testing.T) {
theirs := accounting.NewRates(time.Now())
theirs.Tokens.Current = map[string]int64{"key-1": 900}
leader := &fakeLeaderRouting{routing: control.Routing{Rates: &theirs}}
c := newForwardClusterWith(t, leader)
u := &sharingUsage{own: accounting.NewRates(time.Now())}
u.own.Requests.Current = map[string]int64{"key-1": 4}
s := newStandbyRoutes(c.standby, u, nil, nil, slog.New(slog.DiscardHandler))
if err := s.exchange(t.Context()); err != nil {
t.Fatal(err)
}
act := leader.last()
if act.From != "ctl-b" || act.Rates == nil || act.Rates.Requests.Current["key-1"] != 4 {
t.Errorf("reported %+v", act)
}
if u.others == nil || u.others.Tokens.Current["key-1"] != 900 {
t.Errorf("the router was told %+v", u.others)
}
}
func TestAStandbyFollowsTheLeadersReplicas(t *testing.T) {
leader := &fakeLeaderRouting{routing: control.Routing{Catalog: "a/1",
Replicas: []control.RoutedReplica{{ID: "llama-r0", Model: "llama", Endpoint: "http://10.0.0.5:20001"}}}}
c := newForwardClusterWith(t, leader)
used := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC)
refreshed := 0
s := newStandbyRoutes(c.standby, fixedUsage{"llama-r0": used}, func(_ context.Context) error {
refreshed++
return nil
}, nil, slog.New(slog.DiscardHandler))
if _, ok := s.Ready(); ok {
t.Fatal("ready before hearing from the leader")
}
if err := s.exchange(t.Context()); err != nil {
t.Fatal(err)
}
if reps, ok := s.Ready(); !ok || len(reps) != 1 || reps[0].ID != "llama-r0" {
t.Fatalf("replicas %+v, %v", reps, ok)
}
if got := leader.last().LastUsed["llama-r0"]; !got.Equal(used) {
t.Errorf("the leader was told the replica was last used %v", got)
}
if refreshed != 1 {
t.Errorf("the catalog was read %d times on first hearing from the leader", refreshed)
}
// Only what is new is reported, and the catalog is read again only when
// the leader's changed.
s.wanted("flux")
if err := s.exchange(t.Context()); err != nil {
t.Fatal(err)
}
if act := leader.last(); len(act.LastUsed) != 0 || !slices.Equal(act.Want, []string{"flux"}) {
t.Errorf("second report %+v", act)
}
if refreshed != 1 {
t.Errorf("an unchanged catalog was read again")
}
leader.mu.Lock()
leader.routing.Catalog = "a/2"
leader.mu.Unlock()
if err := s.exchange(t.Context()); err != nil {
t.Fatal(err)
}
if act := leader.last(); len(act.Want) != 0 {
t.Errorf("an on-demand model reported twice: %+v", act)
}
if refreshed != 2 {
t.Errorf("the changed catalog was not read: %d", refreshed)
}
// Unheard for longer than it is trusted, it routes nothing, and requests
// go to the leader again.
c.standby.routes = s
if !c.standby.answersHere("/v1/chat/completions") {
t.Error("a standby with replicas passed the API on")
}
s.now = func() time.Time { return time.Now().Add(standbyTrust + time.Second) }
if _, ok := s.Ready(); ok || c.standby.answersHere("/v1/chat/completions") {
t.Error("a standby the leader has not answered for a minute still routes")
}
}
// The client API stays on a standby with replicas; the portal, its admin
// pages and /mcp still go to the leader.
func TestAStandbyAnswersOnlyTheAPIItself(t *testing.T) {
leader := &fakeLeaderRouting{routing: control.Routing{
Replicas: []control.RoutedReplica{{ID: "llama-r0", Model: "llama", Endpoint: "http://10.0.0.5:20001"}}}}
c := newForwardClusterWith(t, leader)
s := newStandbyRoutes(c.standby, nil, nil, nil, slog.New(slog.DiscardHandler))
if err := s.exchange(t.Context()); err != nil {
t.Fatal(err)
}
c.standby.routes = s
port := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("leader " + r.URL.Path))
}))
defer port.Close()
_, p, _ := net.SplitHostPort(strings.TrimPrefix(port.URL, "http://"))
local := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("standby " + r.URL.Path)) })
srv := httptest.NewServer(c.standby.public(local, p))
defer srv.Close()
for path, want := range map[string]string{
"/v1/chat/completions": "standby", "/v1/models": "standby",
"/admin/replicas": "leader", "/mcp": "leader", "/": "leader",
} {
resp, err := http.Get(srv.URL + path) //nolint:noctx // a test
if err != nil {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if got, _, _ := strings.Cut(string(b), " "); got != want {
t.Errorf("%s answered by %q, want the %s", path, b, want)
}
}
}
+109 -11
View File
@@ -16,6 +16,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/agentsvc"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
@@ -97,10 +98,21 @@ func (m *consoleModel) openForm(f *formDialog) {
// call runs one admin API request in the background and reports it.
func (m *consoleModel) call(title, method, path string, body any, say func(raw json.RawMessage) string) tea.Cmd {
return m.callWaiting(title, method, path, body, say, 60*time.Second)
}
// callWaiting is call for a change the server may spend longer on than the
// console's usual wait.
func (m *consoleModel) callWaiting(title, method, path string, body any, say func(raw json.RawMessage) string,
wait time.Duration,
) tea.Cmd {
m.busy = title
c := m.client
if wait > 60*time.Second {
c = c.patient(wait)
}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
ctx, cancel := context.WithTimeout(context.Background(), wait)
defer cancel()
var raw json.RawMessage
// A change may answer with no body at all.
@@ -334,10 +346,12 @@ func (m consoleModel) modelMenu(md adminapi.ModelView) *menuDialog {
}},
{"Context length…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Context of " + md.Name,
note: "What one request may use. The engine's arguments and the memory change with it, checked against " +
"what the cards hold; its replicas are then started again on it, one by one.",
note: "What one request may use, and the longest answer. A changed context changes the engine's arguments " +
"and memory, checked against what the cards hold, and starts its replicas again one by one; the " +
"longest answer alone changes only the gateway's limit, at once.",
fields: []formField{
textF("tokens", strconv.Itoa(md.ContextLength), "a request's prompt and answer together"),
textF("longest answer", strconv.Itoa(md.MaxOutput), "tokens; empty follows the context"),
textF("slots", "", "llama.cpp: requests at once; empty keeps it"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
@@ -345,14 +359,25 @@ func (m consoleModel) modelMenu(md adminapi.ModelView) *menuDialog {
if err != nil || tokens < 256 {
return nil, fmt.Errorf("the context is a whole number of tokens, at least 256")
}
slots := 0
maxOut := 0
if v[1] != "" {
if slots, err = strconv.Atoi(v[1]); err != nil || slots < 1 {
if maxOut, err = strconv.Atoi(v[1]); err != nil || maxOut < 1 {
return nil, fmt.Errorf("the longest answer is a whole number of tokens")
}
}
slots := 0
if v[2] != "" {
if slots, err = strconv.Atoi(v[2]); err != nil || slots < 1 {
return nil, fmt.Errorf("slots is a whole number from 1")
}
}
return m.call("Context of "+md.Name, http.MethodPost, path+"/context",
modelserve.ContextRequest{Tokens: tokens, Slots: slots}, contextSays), nil
req := modelserve.ContextRequest{Tokens: tokens, Slots: slots, MaxOutput: maxOut}
if tokens == md.ContextLength && slots == 0 && maxOut > 0 {
// The context as it is: only the longest answer, and
// no replica started again for it.
req.Tokens = 0
}
return m.call("Context of "+md.Name, http.MethodPost, path+"/context", req, contextSays), nil
}})
return nil
}},
@@ -479,13 +504,40 @@ func (m consoleModel) modelMenu(md adminapi.ModelView) *menuDialog {
return m.call("Retry "+md.Name, http.MethodPost, path+"/retry", map[string]string{}, nil)
}})
}
if md.Stopped {
items = append(items, menuItem{"Start", func(m *consoleModel) tea.Cmd {
return m.call("Start "+md.Name, http.MethodPost, path+"/start", nil, func(json.RawMessage) string {
return md.Name + " runs again, scaled as it was when it was stopped."
})
}})
} else {
items = append(items, menuItem{"Stop…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Stop " + md.Name,
text: "Its replicas stop; it stays in the catalog with its files, and Start runs it again as it was.",
yes: func(m *consoleModel) tea.Cmd {
return m.call("Stop "+md.Name, http.MethodPost, path+"/stop", nil, func(json.RawMessage) string {
return md.Name + " is stopped and kept."
})
}}
return nil
}})
}
items = append(items, menuItem{"Remove…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Remove " + md.Name,
text: "Its replicas stop and it leaves the catalog. A model from server.yaml cannot be removed here.",
text: "Its replicas stop and it leaves the catalog; its files stay on the mirror and the nodes. " +
"A model from server.yaml cannot be removed here.",
yes: func(m *consoleModel) tea.Cmd {
return m.call("Remove "+md.Name, http.MethodDelete, path, nil, nil)
}}
return nil
}}, menuItem{"Remove with its files…", func(m *consoleModel) tea.Cmd {
m.confirmD = &confirmDialog{title: "Remove " + md.Name + " and its files",
text: "It leaves the catalog, every version of it is deleted from the mirror, and the nodes' copies once " +
"its replicas have stopped. What another model uses stays. This cannot be undone: adding it again fetches it again.",
yes: func(m *consoleModel) tea.Cmd {
return m.callWaiting("Remove "+md.Name, http.MethodDelete, path+"?purge=true", nil, purgeSays, 3*time.Minute)
}}
return nil
}})
return &menuDialog{title: md.Name, items: items}
}
@@ -516,7 +568,7 @@ func (m consoleModel) nodeMenu(n adminapi.NodeView) *menuDialog {
}
items = append(items, m.locationItems(n)...)
if n.GPUTotal > 0 {
for _, engine := range []string{"vllm", "sglang"} {
for _, engine := range []string{"vllm", "sglang", "freetoken"} {
items = append(items, menuItem{engine + " build…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: engine + " on " + n.ID,
note: "Which build of " + engine + " this node installs: auto follows its hardware, none installs none.",
@@ -533,9 +585,15 @@ func (m consoleModel) nodeMenu(n adminapi.NodeView) *menuDialog {
menuItem{"Upgrade its agent…", func(m *consoleModel) tea.Cmd {
m.openForm(&formDialog{title: "Upgrade " + n.ID,
note: "It fetches the release for its platform, checks it, swaps it in and restarts; engines keep serving.",
fields: []formField{textF("version", version.Version, "a release, like v0.11.1")},
fields: []formField{textF("version", releaseOrEmpty(version.Version), "a release, like v0.11.1")},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
return m.call("Upgrade "+n.ID, http.MethodPost, path+"/upgrade", adminapi.UpgradeRequest{Version: v[0]}, nil), nil
if !release.Tag.MatchString(v[0]) {
return nil, fmt.Errorf("a release is vMAJOR.MINOR.PATCH, like v0.15.3")
}
// As long as the control plane waits: the download, the
// restart, the node back on the version.
return m.callWaiting("Upgrade "+n.ID, http.MethodPost, path+"/upgrade", adminapi.UpgradeRequest{Version: v[0]},
func(json.RawMessage) string { return n.ID + " is on " + v[0] + "." }, agentsvc.UpgradeWait+time.Minute), nil
}})
return nil
}},
@@ -954,6 +1012,9 @@ func contextSays(raw json.RawMessage) string {
if json.Unmarshal(raw, &v) != nil {
return "Done."
}
if v.OutputOnly {
return fmt.Sprintf("%s: answers up to %d tokens (was %d); no replica was started again.", v.Model, v.MaxOutput, v.MaxOutputBefore)
}
s := fmt.Sprintf("%s: %d → %d tokens a request, answers up to %d.", v.Model, v.Before, v.After, v.MaxOutput)
for _, p := range v.Profiles {
if p.After != "" {
@@ -990,3 +1051,40 @@ func gpusSays(raw json.RawMessage) string {
}
return s
}
// purgeSays is a model removed with its files, in a few lines.
func purgeSays(raw json.RawMessage) string {
var v adminapi.PurgeView
if json.Unmarshal(raw, &v) != nil {
return "Done."
}
s := v.Removed + " removed."
if len(v.Mirror) > 0 {
s += fmt.Sprintf("\nMirror: %d version(s) deleted.", len(v.Mirror))
}
if len(v.Kept) > 0 {
s += "\nKept on the mirror, another model uses them: " + strings.Join(v.Kept, ", ")
}
var files int
var freed int64
for _, n := range v.Nodes {
files += n.Files
freed += n.Bytes
}
if files > 0 {
s += fmt.Sprintf("\nNodes: %d file(s), %s freed.", files, humanBytes(freed))
}
if v.Note != "" {
s += "\n" + v.Note
}
return s
}
// releaseOrEmpty is this build's version when it is a release, else empty:
// a development build's is not one a node can fetch.
func releaseOrEmpty(v string) string {
if release.Tag.MatchString(v) {
return v
}
return ""
}
+4
View File
@@ -1497,6 +1497,10 @@ func (m consoleModel) gpuRows() [][]string {
}
if !mig {
busy = fmt.Sprintf("%.0f%%", s.UtilPercent)
if p := s.Profile; p != nil && p.SMActive >= 0 {
// How much of the chip, beside whether anything ran.
busy += fmt.Sprintf(" · SM %.0f%%", p.SMActive)
}
if s.MemoryTotalMB > 0 {
mem = fmt.Sprintf("%.1f/%.0f GB", s.MemoryUsedMB/1024, s.MemoryTotalMB/1024)
}
+12 -3
View File
@@ -206,6 +206,9 @@ func (m *consoleModel) serveForm(p modelserve.Plan, a modelserve.PlannedArtifact
if a.Format == "safetensors" {
ctxDefault = strconv.Itoa(p.Context)
}
if len(opts) > 0 && opts[0].Engine == "strata" {
ctxDefault = "32768"
}
fields := []formField{
textF("name", hf.ModelName(p.Repo), "what clients ask for"),
textF("replicas", "1", "0 runs none until it is first asked for"),
@@ -241,12 +244,18 @@ func (m *consoleModel) serveForm(p modelserve.Plan, a modelserve.PlannedArtifact
if sized := o.WithContext(p.KVBytesPerToken, ctxLen); !sized.Fits() {
return nil, fmt.Errorf("with a %d-token context it needs %s: a shorter context, or another way", ctxLen, sized.MemoryText())
}
if o.CPU {
switch {
case o.Engine == "strata":
// One card, the experts in RAM; Strata reads the pictures
// with a reader of its own, not this projector.
req.Engine, req.GPUClass, proj = "strata", o.Class, -1
case o.CPU:
req.CPU = true
} else {
default:
req.Engine = "llamacpp"
req.GPUClass, req.GPUsPerNode, req.Nodes = o.Class, o.GPUsPerNode, max(o.Nodes, 1)
}
req.Fallbacks = strings.HasPrefix(v[5], "the next") && len(opts) > 1
req.Fallbacks = strings.HasPrefix(v[5], "the next") && len(opts) > 1 && o.Engine != "strata"
if proj >= 0 && !strings.HasPrefix(v[proj], "none") {
req.Projector = v[proj]
}
+37 -4
View File
@@ -14,6 +14,7 @@ import (
"github.com/charmbracelet/lipgloss"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/promq"
)
@@ -80,6 +81,9 @@ func (s *monitorState) sample(d consoleData) {
for _, g := range l.GPUs {
card := nd.ID + ":" + strconv.Itoa(g.Index)
add("gpu/"+card, g.UtilPercent)
if w := gpuWork(g); w >= 0 {
add("gpuwork/"+card, w)
}
if g.MemoryTotalMB > 0 {
add("vram/"+card, 100*g.MemoryUsedMB/g.MemoryTotalMB)
}
@@ -142,6 +146,7 @@ func monitorQueries(window time.Duration, points int) (time.Duration, []adminapi
{ID: "cpu", Expr: `avg(llmgw_node_cpu_utilization_percent)`},
{ID: "mem", Expr: `100 * sum(llmgw_node_memory_used_bytes) / sum(llmgw_node_memory_bytes)`},
{ID: "gpu", Expr: card(`llmgw_gpu_utilization_percent`), By: "card"},
{ID: "gpuwork", Expr: card(gpuWorkExpr("")), By: "card"},
{ID: "vram", Expr: card(`100 * llmgw_gpu_memory_used_bytes / llmgw_gpu_memory_total_bytes`), By: "card"},
{ID: "gputemp", Expr: card(`llmgw_gpu_temperature_celsius`), By: "card"},
{ID: "gpupow", Expr: card(`llmgw_gpu_power_watts`), By: "card"},
@@ -314,7 +319,8 @@ func (m consoleModel) monitorPanel(pw int) (string, string, string) {
b.WriteString(indent(side(w, box("CPU", pct("cpu"), m.series("cpu"), 100),
box("Memory", pct("mem"), m.series("mem"), 100)), " ") + "\n\n")
if g := m.series("gpu"); len(g) > 0 || len(m.data.sum.GPUs) > 0 {
b.WriteString(indent(side(w, box("GPU busy", "", g, 100), box("GPU memory", "", m.series("vram"), 100)), " ") + "\n\n")
b.WriteString(indent(side(w, box("GPU busy", "", g, 100), box("GPU work", "", m.series("gpuwork"), 100)), " ") + "\n\n")
b.WriteString(indent(side(w, box("GPU memory", "", m.series("vram"), 100), " "+sDim.Render(gpuWorkNote)), " ") + "\n\n")
}
if m.mon.res.Configured {
rps := m.series("rps")
@@ -356,10 +362,12 @@ func (m consoleModel) monitorPanel(pw int) (string, string, string) {
// Each card: busy, memory, heat and power.
if gpus := m.series("gpu"); len(gpus) > 0 {
vram, temp, pow := byName(m.series("vram")), byName(m.series("gputemp")), byName(m.series("gpupow"))
work := byName(m.series("gpuwork"))
b.WriteString("\n " + mdBold.Render("GPUs") + "\n")
for _, g := range gpus {
fmt.Fprintf(&b, " %-16s busy %s %4.0f%% memory %s %4.0f%% %3.0f°C %s %4.0f W\n", truncate(g.name, 16),
sparkSeries(g, sw/2, 100), last(g.values), sparkSeries(vram[g.name], sw/2, 100), last(vram[g.name].values),
fmt.Fprintf(&b, " %-16s busy %s %4.0f%% work %s %4.0f%% memory %s %4.0f%% %3.0f°C %s %4.0f W\n", truncate(g.name, 16),
sparkSeries(g, sw/3, 100), last(g.values), sparkSeries(work[g.name], sw/3, 100), last(work[g.name].values),
sparkSeries(vram[g.name], sw/3, 100), last(vram[g.name].values),
last(temp[g.name].values), sparkSeries(temp[g.name], 8, 100), last(pow[g.name].values))
}
}
@@ -373,6 +381,29 @@ func (m consoleModel) monitorPanel(pw int) (string, string, string) {
return "Monitor", head.String() + b.String(), keys
}
// GPU busy is the driver's figure: the share of time any kernel ran, which
// a kernel on one SM of a hundred fills. GPU work is how much of the chip:
// the SMs' share of time with work where the card's profiling counters are
// read (GPM, DCGM), and its draw against its power limit elsewhere.
const gpuWorkNote = "busy: some kernel ran. work: how much of the chip —\nSMs active where the counters are read, power/limit elsewhere."
// gpuWorkExpr is GPU work in PromQL, for the series matching sel.
func gpuWorkExpr(sel string) string {
return `llmgw_gpu_sm_active_percent` + sel + ` or (100 * llmgw_gpu_power_watts` + sel +
` / llmgw_gpu_power_limit_watts` + sel + `)`
}
// gpuWork is GPU work from a sample, or -1 without the figures for it.
func gpuWork(g inventory.GPUSensor) float64 {
if p := g.Profile; p != nil && p.SMActive >= 0 {
return p.SMActive
}
if l := g.PowerLoad(); l >= 0 {
return 100 * l
}
return -1
}
func named(s []chartSeries, name string) []chartSeries {
for i := range s {
s[i].name = name
@@ -410,6 +441,7 @@ func detailQueries(kind, id string, window time.Duration, points int) (time.Dura
{ID: "cpu", Expr: `llmgw_node_cpu_utilization_percent{node=` + q + `}`},
{ID: "mem", Expr: `100 * llmgw_node_memory_used_bytes{node=` + q + `} / llmgw_node_memory_bytes{node=` + q + `}`},
{ID: "gpu", Expr: card(`llmgw_gpu_utilization_percent{node=` + q + `}`), By: "card"},
{ID: "gpuwork", Expr: card(gpuWorkExpr(`{node=` + q + `}`)), By: "card"},
{ID: "vram", Expr: card(`100 * llmgw_gpu_memory_used_bytes{node=` + q + `} / llmgw_gpu_memory_total_bytes{node=` + q + `}`), By: "card"},
{ID: "netin", Expr: `sum(rate(node_network_receive_bytes_total{` + netDevs + `,node=` + q + `}[` + rw + `]))`},
{ID: "netout", Expr: `sum(rate(node_network_transmit_bytes_total{` + netDevs + `,node=` + q + `}[` + rw + `]))`},
@@ -520,7 +552,8 @@ func (m consoleModel) detailCharts(pw int) string {
b.WriteString(indent(side(w, box("CPU", fmt.Sprintf("%.0f%%", lastOf(cpu)), cpu, 100),
box("Memory", fmt.Sprintf("%.0f%%", lastOf(mem)), mem, 100)), " "))
if g := get("gpu"); len(g) > 0 {
b.WriteString("\n\n" + indent(side(w, box("GPU busy", "", g, 100), box("GPU memory", "", get("vram"), 100)), " "))
b.WriteString("\n\n" + indent(side(w, box("GPU busy", "", g, 100), box("GPU work", "", get("gpuwork"), 100)), " "))
b.WriteString("\n\n" + indent(side(w, box("GPU memory", "", get("vram"), 100), " "+sDim.Render(gpuWorkNote)), " "))
}
if m.det.res.Configured {
net := append(named(get("netin"), "in"), named(get("netout"), "out")...)
+24 -5
View File
@@ -641,6 +641,19 @@ type Router struct {
// written into the setup scripts and whose attempts are counted where.
TrustedProxies []string `koanf:"trusted_proxies"`
// 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.
ActiveActive bool `koanf:"active_active"`
Timeouts Timeouts `koanf:"timeouts"`
Scoring Scoring `koanf:"scoring"`
Admission Admission `koanf:"admission"`
@@ -1069,12 +1082,18 @@ func (s Server) ControlPlaneMode() bool {
// DefaultEngines are the engine versions this release was checked against:
// every flag llmgw builds for them is one they take.
//
// vLLM is installed on every node with an NVIDIA GPU it can run on and
// skipped elsewhere. SGLang is not here: it is opt-in, by naming a version
// under engines.
// vLLM and FreeToken are installed on every node with an NVIDIA GPU they
// can run on and skipped elsewhere — FreeToken on cards of 8.0 and newer
// with the CUDA 13 toolkit — so a node that can serve a model too large for
// its cards has the engine for it before anyone asks. SGLang is not here:
// it is opt-in, by naming a version under engines.
//
// FreeToken is followed release by release: a new one on PyPI is moved to
// here once ft serve has been checked to take the flags llmgw gives it.
var DefaultEngines = map[string]string{
"llamacpp": "b10991",
"vllm": "0.30.0",
"llamacpp": "b10991",
"vllm": "0.30.0",
"freetoken": "0.2.1",
}
// EngineOverride gives the nodes it picks different engines. Every selector
+53 -3
View File
@@ -60,6 +60,9 @@ type API struct {
controlPlanes func() ControlPlanes
// serve runs registry-to-replica jobs; nil without a mirror and a store.
serve *modelserve.Server
// purgeMirror is the mirror a model removed with its files is removed
// from; nil without one.
purgeMirror PurgeMirror
// history is the Prometheus the console's charts read from.
history History
// audit reads the audit log.
@@ -123,6 +126,8 @@ func (a *API) Register(mux Mux) {
mux.HandleFunc("POST /api/v1/models", a.addModel)
mux.HandleFunc("GET /api/v1/models/{name}", a.getModel)
mux.HandleFunc("DELETE /api/v1/models/{name}", a.removeModel)
mux.HandleFunc("POST /api/v1/models/{name}/stop", a.stopModel)
mux.HandleFunc("POST /api/v1/models/{name}/start", a.startModel)
mux.HandleFunc("POST /api/v1/models/{name}/scale", a.scaleModel)
mux.HandleFunc("POST /api/v1/models/{name}/resources", a.setResources)
a.registerQuotas(mux)
@@ -177,6 +182,7 @@ func (a *API) Register(mux Mux) {
a.registerPresets(mux)
mux.HandleFunc("GET /api/v1/models", a.listModels)
mux.HandleFunc("GET /api/v1/replicas", a.listReplicas)
a.registerRouting(mux)
// Listing is a read and deleting is a separate verb, so a mistyped
// command cannot remove anything.
mux.HandleFunc("GET /api/v1/weights/orphans", a.listOrphans)
@@ -451,6 +457,8 @@ type ModelView struct {
// answer; 0 when the catalog does not say.
ContextLength int `json:"context_length,omitempty"`
MaxOutput int `json:"max_output,omitempty"`
// Stopped is a model an administrator took out of service, kept.
Stopped bool `json:"stopped,omitempty"`
// Canary is a share of its requests sent to a candidate; CanaryOf is
// set on the candidate, naming the model it is a canary of.
Canary *catalog.Canary `json:"canary,omitempty"`
@@ -601,6 +609,9 @@ type FetchView struct {
// invented percentage that stops moving is worse than a byte count that
// keeps rising.
Percent *int `json:"percent,omitempty"`
// Phase is empty while the bytes arrive, "checking" while a set's files
// are checked against the mirror's checksums.
Phase string `json:"phase,omitempty"`
}
// ExplainView is why a model could not be placed.
@@ -843,7 +854,7 @@ func (a *API) Models() []ModelView {
h := health[m.Name]
v := ModelView{
Name: m.Name, Description: m.Description, Aliases: m.Aliases, Source: m.Source,
ContextLength: m.ContextLength, MaxOutput: m.MaxOutput,
ContextLength: m.ContextLength, MaxOutput: m.MaxOutput, Stopped: m.Stopped != nil,
Min: m.Scaling.Min, Max: m.Scaling.Max,
Ready: ready[m.Name], Total: total[m.Name],
Fallbacks: m.Fallbacks,
@@ -915,7 +926,7 @@ func (a *API) Replicas(model string) []ReplicaView {
}
if f := s.Fetch; f != nil {
sv.Fetch = &FetchView{
Source: f.Source, BytesDone: f.BytesDone, BytesTotal: f.BytesTotal,
Source: f.Source, BytesDone: f.BytesDone, BytesTotal: f.BytesTotal, Phase: f.Phase,
}
if p, ok := f.Percent(); ok {
sv.Fetch.Percent = &p
@@ -945,6 +956,8 @@ type Models interface {
Add(ctx context.Context, m catalog.Model, by string) error
Remove(ctx context.Context, name string) error
Scale(ctx context.Context, name string, s catalog.Scaling, by string) (catalog.Model, error)
Stop(ctx context.Context, name, by string) (catalog.Model, error)
Start(ctx context.Context, name, by string) (catalog.Model, error)
Resources(ctx context.Context, name, profile string, memoryGB, cpuCores int, by string) (catalog.Model, error)
SetClass(ctx context.Context, name, class, by string) (catalog.Model, error)
SetOwner(ctx context.Context, name, owner, by string) (catalog.Model, error)
@@ -1194,6 +1207,8 @@ type ScaleResponse struct {
Max int `json:"max"`
IdleTimeout time.Duration `json:"idle_timeout,omitempty"`
OnDemand bool `json:"on_demand"`
// Stopped is a model taken out of service, kept, until it is started.
Stopped bool `json:"stopped,omitempty"`
}
func (a *API) scaleModel(w http.ResponseWriter, r *http.Request) {
@@ -1227,6 +1242,32 @@ func (a *API) scaleModel(w http.ResponseWriter, r *http.Request) {
})
}
// stopModel takes a model out of service, kept.
func (a *API) stopModel(w http.ResponseWriter, r *http.Request) {
a.stopOrStart(w, r, a.models.Stop)
}
// startModel runs a stopped model again.
func (a *API) startModel(w http.ResponseWriter, r *http.Request) {
a.stopOrStart(w, r, a.models.Start)
}
func (a *API) stopOrStart(w http.ResponseWriter, r *http.Request, f func(context.Context, string, string) (catalog.Model, error)) {
if a.models == nil {
writeError(w, http.StatusNotImplemented, "this control plane has no store")
return
}
m, err := f(r.Context(), r.PathValue("name"), actorOf(r))
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
writeJSON(w, http.StatusOK, ScaleResponse{
Model: m.Name, Min: m.Scaling.Min, Max: m.Scaling.Max,
IdleTimeout: m.Scaling.IdleTimeout, OnDemand: m.Scaling.OnDemand(), Stopped: m.Stopped != nil,
})
}
// setResources sets a model's reserved memory and cores. Placed replicas keep
// what they were started with until they are restarted.
func (a *API) setResources(w http.ResponseWriter, r *http.Request) {
@@ -1253,6 +1294,10 @@ func (a *API) removeModel(w http.ResponseWriter, r *http.Request) {
return
}
name := r.PathValue("name")
if r.URL.Query().Get("purge") == "true" {
a.purgeModel(w, r, name)
return
}
if err := a.models.Remove(r.Context(), name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
@@ -1898,7 +1943,7 @@ func PoolNodesOf(nodes []NodeView) []modelplan.PoolNode {
continue
}
p := modelplan.PoolNode{ID: n.ID, GPUs: n.GPUTotal, FreeGPUs: n.GPUFree, HostMemGB: n.MemoryGB,
HostFreeGB: n.MemoryGB}
HostFreeGB: n.MemoryGB, Engines: n.Engines}
if len(n.Classes) > 0 {
p.Class = n.Classes[0]
p.GPUMemGB = modelplan.MemFromClass(p.Class)
@@ -1913,6 +1958,11 @@ func PoolNodesOf(nodes []NodeView) []modelplan.PoolNode {
if len(n.Fabrics) > 0 {
p.Fabric = n.Fabrics[0]
}
for _, c := range n.ComputeCapabilities {
if v, err := strconv.ParseFloat(c, 64); err == nil && v > p.CC {
p.CC = v
}
}
if l := n.Load; l != nil && l.MemTotal > 0 {
p.HostFreeGB = int((l.MemTotal - l.MemUsed) >> 30)
}
+8
View File
@@ -749,3 +749,11 @@ func TestANodesCardInTheWrongSlotIsAWarning(t *testing.T) {
t.Errorf("warnings %q", warned)
}
}
func (f *fakeModels) Stop(_ context.Context, name, _ string) (catalog.Model, error) {
return catalog.Model{Name: name, Stopped: &catalog.Scaling{Min: 1, Max: 1}}, f.err
}
func (f *fakeModels) Start(_ context.Context, name, _ string) (catalog.Model, error) {
return catalog.Model{Name: name, Scaling: catalog.Scaling{Min: 1, Max: 1}}, f.err
}
+230
View File
@@ -0,0 +1,230 @@
package adminapi
import (
"context"
"log/slog"
"net/http"
"net/url"
"path/filepath"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/weights"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
)
// A model removed with its files: the catalog entry, every version of it the
// mirror holds (and so, carried by its removal, a peer mirror's), and the
// copies the nodes fetched, once its replicas have stopped. What another
// model still uses stays.
// PurgeMirror is what a purge asks of the mirror.
type PurgeMirror interface {
List(ctx context.Context) (mirror.ModelsResponse, error)
Remove(ctx context.Context, model, version string) ([]string, error)
}
// SetPurgeMirror gives removing a model with its files the mirror.
func (a *API) SetPurgeMirror(m PurgeMirror) { a.purgeMirror = m }
// purgeWait is how long a purge waits for the model's replicas to stop
// before deleting the nodes' copies; after it, they are left for llmgw ctl
// weights --collect.
var purgeWait = 60 * time.Second
// PurgeView is what a model removed with its files took with it.
type PurgeView struct {
Removed string `json:"removed"`
// Mirror are the versions removed from the mirror, model/version.
Mirror []string `json:"mirror,omitempty"`
// Kept are versions another model still uses, left on the mirror.
Kept []string `json:"kept,omitempty"`
Nodes []CollectResult `json:"nodes,omitempty"`
Note string `json:"note,omitempty"`
}
// purgeModel removes a model from the catalog and its files from the mirror
// and the nodes.
func (a *API) purgeModel(w http.ResponseWriter, r *http.Request, name string) {
m, ok := a.ctl.Catalog().Resolve(name)
if !ok {
writeError(w, http.StatusNotFound, "no model "+name+" in the catalog")
return
}
name = m.Name
others := []catalog.Model{}
for _, o := range a.ctl.Catalog().Models() {
if o.Name != name {
others = append(others, o)
}
}
if err := a.models.Remove(r.Context(), name); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
a.presetDeclined(r.Context(), name, actorOf(r))
out := PurgeView{Removed: name}
sources := purgeSources(m)
if a.purgeMirror == nil {
out.Note = "this control plane has no mirror; its files there, if any, are left"
} else {
out.Mirror, out.Kept, out.Note = purgeFromMirror(r.Context(), a.purgeMirror, sources, others)
}
// The nodes' copies, once nothing runs on them.
deadline := time.Now().Add(purgeWait)
for a.runs(name) && time.Now().Before(deadline) {
select {
case <-r.Context().Done():
return
case <-time.After(time.Second):
}
}
if a.runs(name) {
out.Note = strings.TrimSpace(out.Note + " Its replicas had not stopped yet: its copies on the nodes are " +
"left, and llmgw ctl weights --collect deletes them.")
writeJSON(w, http.StatusOK, out)
return
}
out.Nodes = a.purgeFromNodes(r.Context(), sources, others)
writeJSON(w, http.StatusOK, out)
}
// runs says whether any replica of a model is still on a node.
func (a *API) runs(model string) bool {
for _, rep := range a.ctl.Reconciler().Replicas() {
if rep.Model == model {
return true
}
}
return false
}
// purgeSources are the files a model fetched: its weights, its adapters,
// and the weights its last update replaced.
func purgeSources(m catalog.Model) []string {
out := m.WeightSources()
for _, ad := range m.Adapters {
out = append(out, ad.Source)
}
if m.Origin != nil && m.Origin.Previous != nil && m.Origin.Previous.Source != "" {
out = append(out, m.Origin.Previous.Source)
}
return out
}
// inUse is every file the other models fetch.
func inUse(others []catalog.Model) []string {
var out []string
for _, o := range others {
out = append(out, purgeSources(o)...)
}
return out
}
// purgeFromMirror removes from the mirror every version of the model
// directories the sources are in, but those another model uses.
func purgeFromMirror(ctx context.Context, mc PurgeMirror, sources []string, others []catalog.Model) (removed, kept []string, note string) {
held, err := mc.List(ctx)
if err != nil {
return nil, nil, "the mirror could not be read (" + err.Error() + "): its files there are left"
}
dirs := map[string]bool{}
for _, s := range sources {
if model, _, ok := mirrorPlace(s); ok {
dirs[model] = true
}
}
used := map[string]bool{}
for _, s := range inUse(others) {
if model, version, ok := mirrorPlace(s); ok {
used[model+"/"+version] = true
}
}
seen := map[string]bool{}
for _, st := range held.Models {
key := st.Model + "/" + st.Version
if !dirs[st.Model] || seen[key] {
continue
}
seen[key] = true
if used[key] {
kept = append(kept, key)
continue
}
if _, err := mc.Remove(ctx, st.Model, st.Version); err != nil {
note = strings.TrimSpace(note + " " + key + " could not be removed from the mirror: " + err.Error() + ".")
continue
}
removed = append(removed, key)
}
slices.Sort(removed)
slices.Sort(kept)
return removed, kept, note
}
// mirrorPlace is the model directory and version a mirror URL names:
// BASE/MODEL/VERSION/FILE.
func mirrorPlace(source string) (model, version string, ok bool) {
u, err := url.Parse(source)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return "", "", false
}
// A mirror serves from its root: a registry's address is longer.
parts := strings.Split(strings.Trim(u.Path, "/"), "/")
if len(parts) != 3 {
return "", "", false
}
model, version = parts[0], parts[1]
if mirror.ValidName(model) != nil || mirror.ValidName(version) != nil {
return "", "", false
}
return model, version, true
}
// purgeFromNodes deletes the nodes' copies of the sources: the files llmgw
// fetched for them under any mirror's address, that no other model wants.
func (a *API) purgeFromNodes(ctx context.Context, sources []string, others []catalog.Model) []CollectResult {
rm := a.ctl.WeightRemover()
if rm == nil {
return nil
}
names := map[string]bool{}
for _, s := range sources {
names[filepath.Base(s)] = true
}
wanted := inUse(others)
var out []CollectResult
for _, n := range a.ctl.Inventory().Nodes() {
size := map[string]int64{}
have := make([]string, 0, len(n.Weights))
for _, w := range n.Weights {
have = append(have, w.Path)
size[w.Path] = w.SizeBytes
}
var paths []string
for _, p := range weights.Orphans(have, wanted) {
// The cache name is a hash of the address, then the file's own
// name: the address may be either mirror's, the name is the same.
if _, file, ok := strings.Cut(filepath.Base(p), "-"); ok && names[file] {
paths = append(paths, p)
}
}
if len(paths) == 0 {
continue
}
res := CollectResult{Node: n.ID, Files: len(paths)}
for _, p := range paths {
res.Bytes += size[p]
}
if err := rm.RemoveWeights(ctx, n.ID, paths); err != nil {
res.Error, res.Files, res.Bytes = err.Error(), 0, 0
slog.Warn("a purged model's copies could not be deleted on a node", "node", n.ID, "err", err)
}
out = append(out, res)
}
return out
}
+68
View File
@@ -0,0 +1,68 @@
package adminapi
import (
"context"
"slices"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
)
type purgeMirrorFake struct {
held []mirror.Status
removed []string
}
func (f *purgeMirrorFake) List(context.Context) (mirror.ModelsResponse, error) {
return mirror.ModelsResponse{Base: "http://m:8081", Models: f.held}, nil
}
func (f *purgeMirrorFake) Remove(_ context.Context, model, version string) ([]string, error) {
f.removed = append(f.removed, model+"/"+version)
return nil, nil
}
func TestAPurgeRemovesEveryVersionOfTheModelButWhatAnotherUses(t *testing.T) {
f := &purgeMirrorFake{held: []mirror.Status{
{Model: "qwen", Version: "v2", File: "a.gguf"},
{Model: "qwen", Version: "v2", File: "b.gguf"},
{Model: "qwen", Version: "v1", File: "a.gguf"},
{Model: "qwen", Version: "v3", File: "a.gguf"},
{Model: "other", Version: "v1", File: "o.gguf"},
{Model: "lora", Version: "t1", File: "l.gguf"},
}}
m := catalog.Model{Name: "qwen", Source: "http://m:8081/qwen/v2/a.gguf",
Adapters: []catalog.Adapter{{Name: "tone", Source: "http://m:8081/lora/t1/l.gguf"}}}
// The canary runs v3: it stays.
others := []catalog.Model{{Name: "qwen-canary", Source: "http://other-mirror:8081/qwen/v3/a.gguf"},
{Name: "other", Source: "http://m:8081/other/v1/o.gguf"}}
removed, kept, note := purgeFromMirror(t.Context(), f, purgeSources(m), others)
if note != "" {
t.Fatal(note)
}
if !slices.Equal(removed, []string{"lora/t1", "qwen/v1", "qwen/v2"}) || !slices.Equal(kept, []string{"qwen/v3"}) {
t.Errorf("removed %v, kept %v", removed, kept)
}
if slices.Contains(f.removed, "other/v1") || len(f.removed) != 3 {
t.Errorf("asked to remove %v", f.removed)
}
}
func TestOnlyAMirrorsAddressNamesAPlaceOnIt(t *testing.T) {
for src, want := range map[string]string{
"http://10.0.0.146:8081/qwen/abc/q4.gguf": "qwen/abc",
"https://huggingface.co/o/r/resolve/main/q4.gguf": "",
"/srv/models/q4.gguf": "",
"Qwen/Qwen2.5-7B": "",
} {
model, version, ok := mirrorPlace(src)
got := ""
if ok {
got = model + "/" + version
}
if got != want {
t.Errorf("%s: %q, want %q", src, got, want)
}
}
}
+21 -3
View File
@@ -6,6 +6,7 @@ import (
llmgw "gitea.mynodes.xyz/ilker/llmgw"
"gitea.mynodes.xyz/ilker/llmgw/internal/bench"
"gitea.mynodes.xyz/ilker/llmgw/internal/bench/eval"
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/fabrics"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/federation"
@@ -155,8 +156,16 @@ var Routes = []Route{
Description: "With the defaults the catalog filled in. By its name only, not an alias.",
Response: catalog.Model{}},
{Method: "DELETE", Path: "/api/v1/models/{name}", Summary: "Remove a model added at runtime",
Description: "A model declared in the cluster's settings is refused: it is changed there.",
Response: Removed{}},
Description: "A model declared in the cluster's settings is refused: it is changed there. With purge, its " +
"files go too: every version of it the mirror holds (a peer mirror follows), and the nodes' copies once its " +
"replicas have stopped, waiting up to a minute for them; what another model uses stays. The answer is then a PurgeView.",
Query: []Param{{Name: "purge", Type: "boolean", Description: "Delete its files from the mirror and the nodes as well."}},
Response: Removed{}},
{Method: "POST", Path: "/api/v1/models/{name}/stop", Summary: "Take a model out of service and keep it",
Description: "None of its replicas run, and requests are told an administrator stopped it; what it was scaled to is kept for start.",
Response: ScaleResponse{}},
{Method: "POST", Path: "/api/v1/models/{name}/start", Summary: "Run a stopped model again, scaled as it was",
Response: ScaleResponse{}},
{Method: "POST", Path: "/api/v1/models/{name}/scale", Summary: "Set how many replicas a model runs",
Request: ScaleRequest{}, Response: ScaleResponse{}},
{Method: "POST", Path: "/api/v1/models/{name}/resources", Summary: "Set what a model's profiles reserve of a node",
@@ -186,6 +195,15 @@ var Routes = []Route{
{Method: "GET", Path: "/api/v1/replicas", Summary: "Every replica, with its shards",
Query: []Param{{Name: "model", Description: "Only this model's replicas."}},
Response: []ReplicaView{}},
{Method: "GET", Path: "/api/v1/routing", Summary: "The Ready replicas a standby routes to",
Description: "Read by a standby control plane when router.active_active is on: each replica's address, " +
"model, profile, pool and weight, without its key, and which catalog is in force.",
Response: control.Routing{}},
{Method: "POST", Path: "/api/v1/routing/activity", Summary: "A standby's router reports what it used",
Description: "When it last sent each replica a request, and the on-demand models it was asked for, " +
"so the leader does not evict a replica busy behind a standby. Answered with the routing, " +
"so one request a tick does both.",
Request: control.Activity{}, Response: control.Routing{}},
{Method: "GET", Path: "/api/v1/explain-placement", Summary: "Why a model could not be placed",
Query: []Param{{Name: "model", Required: true, Description: "The model's name or an alias."}},
Response: ExplainView{}},
@@ -213,7 +231,7 @@ var Routes = []Route{
{Method: "POST", Path: "/api/v1/models/{name}/update", Summary: "Move a model to its repository's newer weights",
Description: "With a quality suite, or the model's standing gate, the new weights are asked it first as a hidden canary.",
Request: modelserve.Change{}, OptionalBody: true, Response: modelserve.Job{}, Status: http.StatusAccepted},
{Method: "POST", Path: "/api/v1/models/{name}/context", Summary: "Change a served model's context: the catalog, its engines' arguments and memory, then its replicas restarted",
{Method: "POST", Path: "/api/v1/models/{name}/context", Summary: "Change a served model's context and longest answer: the catalog, its engines' arguments and memory, then its replicas restarted; max_output alone changes only the catalog and restarts nothing",
Request: modelserve.ContextRequest{}, Response: modelserve.ContextChange{}},
{Method: "POST", Path: "/api/v1/models/{name}/gpus", Summary: "Spread a served model over more cards or fewer: its profile, checked against its KV heads, its cards' memory and the nodes, then its replicas restarted",
Description: "Tensor parallel for vLLM, SGLang and TensorRT-LLM, a layer split for llama.cpp, on one node. With no room beside the running replicas, in_place stops each first.",
+27
View File
@@ -0,0 +1,27 @@
package adminapi
import (
"encoding/json"
"io"
"net/http"
"gitea.mynodes.xyz/ilker/llmgw/internal/control"
)
// registerRouting is what a standby answering requests itself reads from the
// leader, and reports back to it. Reached by the standby through the agent
// port with its control-plane certificate, like the rest of the forwarded API.
func (a *API) registerRouting(mux Mux) {
mux.HandleFunc("GET /api/v1/routing", func(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, a.ctl.Routing())
})
mux.HandleFunc("POST /api/v1/routing/activity", func(w http.ResponseWriter, r *http.Request) {
var act control.Activity
if err := json.NewDecoder(io.LimitReader(r.Body, 8<<20)).Decode(&act); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
a.ctl.RecordActivity(act)
writeJSON(w, http.StatusOK, a.ctl.RoutingFor(act.From))
})
}
+2
View File
@@ -236,6 +236,7 @@ func StartShardFromCommand(cmd reconciler.ShardCommand) *llmgwv1.StartShard {
Adapters: adaptersToWire(p.Adapters),
KvEvents: p.KVEvents,
AllowPickle: p.AllowPickle,
ModelSha256: p.ModelSHA256,
},
StartupTimeoutMs: cmd.StartupTimeout.Milliseconds(),
Pool: cmd.Pool,
@@ -274,6 +275,7 @@ func ShardSpecFromStart(m *llmgwv1.StartShard) (engines.ShardSpec, time.Duration
Adapters: adaptersFromWire(p.GetAdapters()),
KVEvents: p.GetKvEvents(),
AllowPickle: p.GetAllowPickle(),
ModelSHA256: p.GetModelSha256(),
},
}
return spec, time.Duration(m.GetStartupTimeoutMs()) * time.Millisecond
@@ -3,6 +3,7 @@ package agentsvc
import (
"log/slog"
"testing"
"time"
llmgwv1 "gitea.mynodes.xyz/ilker/llmgw/api/gen/llmgw/v1"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
@@ -284,6 +285,28 @@ func TestAnUpgradeWaitsForTheAgentsAnswer(t *testing.T) {
}
}
// The agent's answer lost — it exits a moment after sending it — and the node
// back on the version: the upgrade is done, not five minutes of waiting and a
// failure blaming an old agent.
func TestAnUpgradeIsDoneWhenTheNodeComesBackOnTheVersion(t *testing.T) {
s := newTestService()
nodeWith(s, inventory.Node{ID: "n1", State: inventory.Ready, AgentVersion: "v1.2.2"})
c := &conn{nodeID: "n1", send: make(chan *llmgwv1.ControlMessage, 4), closed: make(chan struct{})}
s.conns["n1"] = c
done := make(chan error, 1)
go func() { done <- s.Upgrade(t.Context(), "n1", "v1.2.3", "https://r") }()
<-c.send
nodeWith(s, inventory.Node{ID: "n1", State: inventory.Ready, AgentVersion: "v1.2.3"})
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(10 * time.Second):
t.Fatal("still waiting for an answer the node will never send")
}
}
func TestAnotherTenantOnACardIsJudgedEveryHeartbeat(t *testing.T) {
// Found on cuda01: ComfyUI held 5 GB when the agent started, so the card
// was kept out of placement. ComfyUI let go of it and the card stayed
+50 -13
View File
@@ -35,7 +35,7 @@ type StatusSink interface {
// OnFetchProgress is separate because it is not a transition: the shard is
// still starting, and treating each report as a state change would reset
// the timers that decide when a slow start has become a failed one.
OnFetchProgress(replicaID, shardID, source string, done, total int64)
OnFetchProgress(replicaID, shardID, source, phase string, done, total int64)
// OnShardCompute records where a shard's engine said it runs the model.
OnShardCompute(replicaID, shardID string, c reconciler.Compute)
Adopt(nodeID string, shards []reconciler.AdoptedShard) (unclaimed []reconciler.AdoptedShard)
@@ -461,7 +461,7 @@ func (s *Service) handle(nodeID string, msg *llmgwv1.AgentMessage) {
// download that takes an hour would keep a start timer at zero.
if f := m.Shard.GetFetch(); f != nil {
s.status.OnFetchProgress(m.Shard.GetReplicaId(), m.Shard.GetShardId(),
f.GetSource(), f.GetBytesDone(), f.GetBytesTotal())
f.GetSource(), f.GetPhase(), f.GetBytesDone(), f.GetBytesTotal())
return
}
if c := m.Shard.GetCompute(); c != nil {
@@ -772,12 +772,25 @@ func loadFrom(l *llmgwv1.NodeLoad, at time.Time) *inventory.Load {
UtilPercent: g.GetUtilizationPercent(), MemUtilPercent: g.GetMemoryUtilizationPercent(),
MemoryUsedMB: g.GetMemoryUsedMb(), MemoryTotalMB: g.GetMemoryTotalMb(),
SMClockMHz: g.GetSmClockMhz(), ECCUncorrected: g.GetEccUncorrected(),
Wear: wearFrom(g),
Wear: wearFrom(g), Profile: profileFrom(g),
})
}
return out
}
// profileFrom is a card's profiling counters, or nil from an agent that
// sent none.
func profileFrom(g *llmgwv1.GPUSensor) *inventory.GPUProfile {
if !g.GetHasProfile() {
return nil
}
return &inventory.GPUProfile{
SMActive: g.GetSmActivePercent(), SMOccupancy: g.GetSmOccupancyPercent(),
TensorActive: g.GetTensorActivePercent(), DRAMActive: g.GetDramActivePercent(),
Source: g.GetProfileSource(),
}
}
func (s *Service) attach(ctx context.Context, nodeID string) *conn {
c := &conn{
nodeID: nodeID,
@@ -1158,21 +1171,45 @@ func (s *Service) Upgrade(ctx context.Context, nodeID, version, releases string)
return err
}
s.log.Info("told a node to upgrade", "node", nodeID, "version", version)
// A download of some tens of megabytes on a slow link, and no longer: an
// agent too old to know this message never answers.
ctx, cancel := context.WithTimeout(ctx, 5*time.Minute)
// Longer than the agent gives its download, so its own answer — done, or
// why not — is what is heard. Its answer can still be lost: it exits a
// moment after sending it, and a stream that breaks takes it along. The
// node registering again on the version says the same, and is watched for.
ctx, cancel := context.WithTimeout(ctx, UpgradeWait)
defer cancel()
select {
case r := <-ch:
if r.GetError() != "" {
return errors.New(r.GetError())
tick := time.NewTicker(2 * time.Second)
defer tick.Stop()
for {
select {
case r := <-ch:
if r.GetError() != "" {
return errors.New(r.GetError())
}
return nil
case <-tick.C:
if n, ok := s.inv.Get(nodeID); ok && n.AgentVersion == version {
return nil
}
case <-ctx.Done():
n, ok := s.inv.Get(nodeID)
switch {
case !slices.Contains(s.ConnectedNodes(), nodeID):
return fmt.Errorf("node %s went away during the upgrade and has not come back: on it, "+
"`systemctl status llmgw-agent` and `journalctl -u llmgw-agent` say why", nodeID)
case ok && n.AgentVersion != "":
return fmt.Errorf("node %s did not answer the upgrade and still runs %s: its download is slow or stuck, "+
"or its agent is older than v0.10.4, which cannot upgrade itself", nodeID, n.AgentVersion)
default:
return fmt.Errorf("node %s did not answer the upgrade; an agent older than v0.10.4 cannot upgrade itself", nodeID)
}
}
return nil
case <-ctx.Done():
return fmt.Errorf("node %s did not answer the upgrade; an agent older than v0.10.4 cannot upgrade itself", nodeID)
}
}
// UpgradeWait is how long the control plane waits for an agent to upgrade:
// longer than the agent gives its download (agent.upgradeDownload).
const UpgradeWait = 11 * time.Minute
// wearFrom is a card's wear, when the agent read it.
func wearFrom(g *llmgwv1.GPUSensor) *inventory.GPUWear {
if !g.GetHasWear() {
+31 -1
View File
@@ -22,6 +22,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/control/facility"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/placement"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
)
// ErrExists is a model added only if absent that was there already.
@@ -332,6 +333,10 @@ type Model struct {
AllowPickle bool `json:"allow_pickle,omitempty" koanf:"allow_pickle"`
Profiles []Profile `json:"profiles,omitempty" koanf:"profiles"`
Scaling Scaling `json:"scaling,omitempty" koanf:"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.
Stopped *Scaling `json:"stopped,omitempty" koanf:"stopped"`
// Class is how much this model's capacity is worth against another's,
// when there is not enough for both: critical, standard or opportunistic.
// Empty is standard, or opportunistic for a model loaded on demand. See
@@ -1197,10 +1202,14 @@ func (m Model) validate() error {
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
return fmt.Errorf("model %q profile %q: strata runs a replica on one GPU of one node", m.Name, p.Name)
}
if !slices.ContainsFunc(p.Args, func(a string) bool { return a == "--family" || strings.HasPrefix(a, "--family=") }) {
family, size := strata.ArgValue(p.Args, "--family"), strata.ArgValue(p.Args, "--size")
if family == "" || size == "" {
return fmt.Errorf("model %q profile %q: strata needs the model's family and size in args "+
"(--family qwen --size IQ2_XS)", m.Name, p.Name)
}
if _, err := strata.SizeOf(family, size); err != nil {
return fmt.Errorf("model %q profile %q: %w", m.Name, p.Name, err)
}
if strings.HasSuffix(strings.ToLower(strings.TrimSuffix(m.SourceOf(p), "/")), ".gguf") {
return fmt.Errorf("model %q profile %q: strata prepares a model from all of its GGUF files; "+
"give the directory they are in as the source", m.Name, p.Name)
@@ -1323,6 +1332,26 @@ func (m Model) SourceOf(p Profile) string {
return m.Source
}
// OriginSHA256 is the sha256 the registry published for the one file a
// source names, from the model's origin: "" for a set, a repository, or a
// file the origin does not list.
func (m Model) OriginSHA256(source string) string {
if m.Origin == nil || strings.HasSuffix(source, "/") {
return ""
}
p := source
if u, err := url.Parse(source); err == nil && u.Path != "" {
p = u.Path
}
name := path.Base(p)
for _, f := range m.Origin.Files {
if f.SHA256 != "" && path.Base(f.Path) == name {
return f.SHA256
}
}
return ""
}
// WeightSources are every source a model's replicas may load: the model's
// and each profile's of its own, once each. What a node must not have
// collected from under it.
@@ -1398,6 +1427,7 @@ func (m Model) EngineProfile(name string) (engines.Profile, bool) {
Adapters: m.engineAdapters(),
KVEvents: p.KVEvents,
AllowPickle: m.AllowPickle,
ModelSHA256: m.OriginSHA256(m.SourceOf(p)),
}.WithRuntimeLoRA(m.MaxLoadedAdapters), true
}
return engines.Profile{}, false
@@ -0,0 +1,27 @@
package catalog
import "testing"
func TestTheFileASourceNamesIsGivenItsPublishedChecksum(t *testing.T) {
m := Model{Name: "q", Source: "http://10.0.0.146:8081/q/abc/q4.gguf", Origin: &Origin{Repo: "o/r", Revision: "abc",
Files: []OriginFile{{Path: "sub/q4.gguf", SHA256: "aaaa"}, {Path: "config.json"}}}}
for src, want := range map[string]string{
m.Source: "aaaa",
"https://huggingface.co/o/r/resolve/abc/sub/q4.gguf": "aaaa",
"http://10.0.0.146:8081/q/abc/": "", // a set
"o/r": "",
"http://10.0.0.146:8081/q/abc/config.json": "", // no checksum published
} {
if got := m.OriginSHA256(src); got != want {
t.Errorf("%s: %q, want %q", src, got, want)
}
}
m.Profiles = []Profile{{Name: "cpu", Engine: "llamacpp"}}
if ep, ok := m.EngineProfile("cpu"); !ok || ep.ModelSHA256 != "aaaa" {
t.Errorf("the node is not told it: %+v", ep)
}
m.Origin = nil
if m.OriginSHA256(m.Source) != "" {
t.Error("a checksum with no origin")
}
}
+7 -1
View File
@@ -20,11 +20,17 @@ func TestAStrataProfileNamesItsFamilyAndRunsOnOneCard(t *testing.T) {
"family and size": {Name: "x", Source: "http://mirror:8081/x/1/", Profiles: []Profile{{
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--size", "IQ2_XS"}}}},
"all of its GGUF files": {Name: "x", Source: "http://mirror:8081/x/1/x-00001-of-00002.gguf", Profiles: []Profile{{
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--family", "qwen", "--size", "IQ2_XS"}}}},
"no family \"llama\"": {Name: "x", Source: "http://mirror:8081/x/1/", Profiles: []Profile{{
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--family", "llama", "--size", "IQ2_XS"}}}},
"comes in IQ1_M": {Name: "x", Source: "http://mirror:8081/x/1/", Profiles: []Profile{{
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--family", "coder", "--size", "IQ2_XS"}}}},
"family and size ": {Name: "x", Source: "http://mirror:8081/x/1/", Profiles: []Profile{{
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--family", "qwen"}}}},
"--gguf-dir": {Name: "x", Source: "http://mirror:8081/x/1/", Profiles: []Profile{{
Name: "p", Engine: "strata", GPUClass: "6000-24g", Args: []string{"--family", "qwen", "--gguf-dir", "/etc"}}}},
} {
if _, err := New([]Model{bad}); err == nil || !strings.Contains(err.Error(), want) {
if _, err := New([]Model{bad}); err == nil || !strings.Contains(err.Error(), strings.TrimSpace(want)) {
t.Errorf("expected %q, got %v", want, err)
}
}
+13 -1
View File
@@ -12,6 +12,7 @@ import (
"os"
"strings"
"sync"
"sync/atomic"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
@@ -95,6 +96,15 @@ type Controller struct {
mu sync.Mutex
last string // fingerprint of the last published list
// feed is where a standby's router learns the leader's Ready replicas,
// when standbys answer requests themselves; nil when they do not.
feed ReplicaFeed
// catRev changes with every catalog put in force here, so a standby
// following this one knows to read the catalog again.
catRev atomic.Uint64
// rates are the standbys' counts against each limit, while this leads.
rates sharedRates
stop chan struct{}
done chan struct{}
once sync.Once
@@ -193,6 +203,7 @@ type LoadSource interface {
// killed.
func (c *Controller) ReplaceCatalog(ctx context.Context, next *catalog.Catalog) {
c.cat.Store(next)
c.catRev.Add(1)
if cs, ok := c.sink.(CatalogSink); ok {
cs.SetCatalog(next)
}
@@ -306,6 +317,7 @@ func (c *Controller) Start(ctx context.Context) {
// Before publishing, so a replica's idleness is as fresh as
// the endpoint list it travels beside.
c.collectUsage()
c.shareRates()
c.Publish(ctx)
}
}
@@ -345,7 +357,7 @@ func (c *Controller) Publish(ctx context.Context) {
// endpoints converts Ready replicas into the router's view.
func (c *Controller) endpoints() []endpoint.Info {
ready := c.rec.ReadyReplicas()
ready := c.routable()
out := make([]endpoint.Info, 0, len(ready))
for _, rep := range ready {
+1 -1
View File
@@ -96,7 +96,7 @@ func EngineBuilds(engine string) []string {
switch engine {
case "vllm":
return append(append([]string{"auto"}, provision.VLLMVariants...), "none")
case "sglang":
case "sglang", "freetoken":
return []string{"auto", "none"}
}
return nil
+35 -4
View File
@@ -84,6 +84,10 @@ type Collector struct {
gpuXID *prometheus.Desc
gpuLastXID *prometheus.Desc
gpuMemUtil *prometheus.Desc
gpuSMActive *prometheus.Desc
gpuSMOccupancy *prometheus.Desc
gpuTensor *prometheus.Desc
gpuDRAMActive *prometheus.Desc
gpuSMClock *prometheus.Desc
gpuTemp *prometheus.Desc
gpuMemTemp *prometheus.Desc
@@ -158,9 +162,22 @@ func New(src Source) *Collector {
gpuMemoryUsed: prometheus.NewDesc("llmgw_gpu_memory_used_bytes",
"A GPU's framebuffer in use, including processes llmgw did not start.", gpu, nil),
gpuUtil: prometheus.NewDesc("llmgw_gpu_utilization_percent",
"How busy a GPU was over the last sample period. Absent when the driver does not "+
"report it, which is not the same as idle. Memory used says a model is loaded; "+
"this says whether it is doing anything.", gpu, nil),
"The share of the last sample period in which any kernel ran on a GPU (nvidia-smi's "+
"utilization.gpu). Not how much of the chip: a kernel on one SM reads 100%. Absent "+
"when the driver does not report it, which is not the same as idle. Memory used says "+
"a model is loaded; this says whether it is doing anything, and "+
"llmgw_gpu_sm_active_percent how much.", gpu, nil),
gpuSMActive: prometheus.NewDesc("llmgw_gpu_sm_active_percent",
"The share of time a GPU's SMs had work, averaged over all of them: how much of the chip "+
"the busy figure is using. From NVML's GPM (Hopper and later) or a dcgm-exporter on the node; "+
"absent elsewhere.", gpu, nil),
gpuSMOccupancy: prometheus.NewDesc("llmgw_gpu_sm_occupancy_percent",
"How full a GPU's active SMs were with warps, of the most they can hold.", gpu, nil),
gpuTensor: prometheus.NewDesc("llmgw_gpu_tensor_active_percent",
"The share of time a GPU's tensor cores were working. Low in decode at batch one is normal; "+
"prefill and batches are where it should rise.", gpu, nil),
gpuDRAMActive: prometheus.NewDesc("llmgw_gpu_dram_active_percent",
"The share of time a GPU's memory interface was moving data, from its profiling counters.", gpu, nil),
gpuPCIeReplay: prometheus.NewDesc("llmgw_gpu_pcie_replays",
"PCIe link retries on a GPU's link since the driver loaded; one that keeps climbing is a riser, slot or cable.", gpu, nil),
gpuRetired: prometheus.NewDesc("llmgw_gpu_retired_pages",
@@ -310,7 +327,8 @@ func (c *Collector) Describe(ch chan<- *prometheus.Desc) {
c.nodeCPU, c.nodeCPUs, c.nodeLoad, c.nodeMemUsed, c.nodeMemAvail, c.shardCPU, c.shardMemory,
c.ibActive, c.ibRate, c.ibCounter, c.fabricSMs, c.fabricMaster, c.fabricProblems,
c.gpuNVLJoined, c.imexUp,
c.gpuMemoryTotal, c.gpuMemoryUsed, c.gpuUtil, c.gpuMemUtil, c.gpuSMClock, c.gpuAssigned, c.gpuTemp, c.gpuHealthy,
c.gpuMemoryTotal, c.gpuMemoryUsed, c.gpuUtil, c.gpuMemUtil, c.gpuSMClock,
c.gpuSMActive, c.gpuSMOccupancy, c.gpuTensor, c.gpuDRAMActive, c.gpuAssigned, c.gpuTemp, c.gpuHealthy,
c.gpuPCIeReplay, c.gpuRetired, c.gpuRetirePend, c.gpuRemapped, c.gpuRemapPend, c.gpuRemapFail,
c.gpuNVLinkErr, c.gpuXID, c.gpuLastXID,
c.gpuMemTemp, c.gpuPower, c.gpuPowerLimit, c.gpuThrottled, c.nodeCPUTemp, c.nodeCPUPower, c.nodeInletTemp,
@@ -653,6 +671,19 @@ func (c *Collector) gpuBusy(ch chan<- prometheus.Metric, s inventory.GPUSensor,
if s.SMClockMHz > 0 {
ch <- prometheus.MustNewConstMetric(c.gpuSMClock, prometheus.GaugeValue, s.SMClockMHz, labels...)
}
// The card's profile, here rather than with the sensors because it is
// the whole card's: a MIG instance must not be published as having it.
if p := s.Profile; p != nil {
for _, v := range []struct {
d *prometheus.Desc
val float64
}{{c.gpuSMActive, p.SMActive}, {c.gpuSMOccupancy, p.SMOccupancy}, {c.gpuTensor, p.TensorActive}, {c.gpuDRAMActive, p.DRAMActive}} {
// Zero is an idle chip; only -1, not reported, is left out.
if v.val >= 0 {
ch <- prometheus.MustNewConstMetric(v.d, prometheus.GaugeValue, v.val, labels...)
}
}
}
}
func (c *Collector) gpuSensor(ch chan<- prometheus.Metric, s inventory.GPUSensor, labels []string) {
+6 -1
View File
@@ -16,7 +16,8 @@ func TestAMIGInstanceHasItsOwnMemoryItsCardsHeatAndNoUtilisation(t *testing.T) {
MIG: &discovery.MIGDevice{ParentIndex: 0, ParentMemoryTotalMB: 81920, Device: 1, Profile: "3g.40gb"}}
n := inventory.Node{ID: "g1", State: inventory.Ready, GPUs: []discovery.GPU{inst},
Load: &inventory.Load{At: time.Now(), GPUs: []inventory.GPUSensor{{Index: 0, TempC: 61, PowerW: 250,
UtilPercent: 0, MemoryUsedMB: 60000, MemoryTotalMB: 81920}}}}
UtilPercent: 0, MemoryUsedMB: 60000, MemoryTotalMB: 81920,
Profile: &inventory.GPUProfile{SMActive: 50, SMOccupancy: 30, TensorActive: 10, DRAMActive: 20}}}}}
out := gather(t, fakeSource{nodes: []inventory.Node{n}})
labels := "class=a100-80g-mig-3g.40gb gpu=1001 node=g1"
for _, want := range []string{
@@ -33,4 +34,8 @@ func TestAMIGInstanceHasItsOwnMemoryItsCardsHeatAndNoUtilisation(t *testing.T) {
if strings.Contains(out, "llmgw_gpu_utilization_percent "+labels) {
t.Errorf("a utilisation was published for a MIG instance:\n%s", out)
}
// The card's profile is the whole card's, not this instance's.
if strings.Contains(out, "llmgw_gpu_sm_active_percent "+labels) {
t.Errorf("the card's SM activity was published for a MIG instance:\n%s", out)
}
}
@@ -127,3 +127,27 @@ func TestAGPUsWearIsPublishedAndWhatItDoesNotKeepIsLeftOut(t *testing.T) {
t.Errorf("a count the card does not keep was published:\n%s", out)
}
}
func TestHowMuchOfTheChipIsPublishedBesideHowBusy(t *testing.T) {
// The Quadro RTX 6000 in decode: 92% busy, its SMs at work 69% of the
// time. The DRAM figure was not read, and is left out rather than zero.
n := inventory.Node{ID: "g1", State: inventory.Ready,
GPUs: []discovery.GPU{{Index: 0, Name: "NVIDIA H100 80GB HBM3", MemoryTotalMB: 81559}},
Load: &inventory.Load{At: time.Now(), GPUs: []inventory.GPUSensor{{Index: 0, UtilPercent: 92, MemoryTotalMB: 81559,
Profile: &inventory.GPUProfile{SMActive: 69, SMOccupancy: 45, TensorActive: 0, DRAMActive: -1, Source: "gpm"}}}}}
out := gather(t, fakeSource{nodes: []inventory.Node{n}})
for _, want := range []string{
"llmgw_gpu_utilization_percent class=h100-80g gpu=0 node=g1 -> 92\n",
"llmgw_gpu_sm_active_percent class=h100-80g gpu=0 node=g1 -> 69\n",
"llmgw_gpu_sm_occupancy_percent class=h100-80g gpu=0 node=g1 -> 45\n",
// Idle tensor cores are a reading.
"llmgw_gpu_tensor_active_percent class=h100-80g gpu=0 node=g1 -> 0\n",
} {
if !strings.Contains(out, want) {
t.Errorf("missing %s in:\n%s", want, out)
}
}
if strings.Contains(out, "llmgw_gpu_dram_active_percent") {
t.Errorf("a figure not read was published:\n%s", out)
}
}
+30
View File
@@ -340,6 +340,36 @@ type GPUSensor struct {
// Wear is what wears the card out, read every minute; nil from an agent
// that does not read it.
Wear *GPUWear `json:"wear,omitempty"`
// Profile is how much of the chip the work uses, which UtilPercent does
// not say; nil where nothing on the node can read it.
Profile *GPUProfile `json:"profile,omitempty"`
}
// GPUProfile is a card's profiling counters, in percent; -1 is not reported.
// UtilPercent is the share of time any kernel ran, and a kernel on one SM of
// a hundred counts in full; these say how much of the chip it ran on.
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 float64 `json:"sm_active"`
SMOccupancy float64 `json:"sm_occupancy"`
// TensorActive is the tensor cores, DRAMActive the memory interface.
TensorActive float64 `json:"tensor_active"`
DRAMActive float64 `json:"dram_active"`
// Source is what read them: "gpm" (NVML, Hopper and later) or "dcgm".
Source string `json:"source,omitempty"`
}
// PowerLoad is the card's draw as a share of its power limit, from 0 to 1,
// or -1 without both figures. Unlike UtilPercent it rises with how much of
// the chip is working, not only with whether anything is: a card reading
// 92% busy drew 239 W of 260 W in decode and 112 W when the busy figure was
// a kernel or two between requests.
func (g GPUSensor) PowerLoad() float64 {
if g.PowerW <= 0 || g.PowerLimitW <= 0 {
return -1
}
return min(g.PowerW/g.PowerLimitW, 1)
}
// GPUWear is a card's wear and failures. Counts of -1 are not reported for
+55 -9
View File
@@ -21,9 +21,13 @@ import (
// ContextRequest is the context to change to: tokens one request may use,
// and for llama.cpp how many at once (its pool is the two multiplied).
// MaxOutput is the longest answer, which may be changed with it or alone:
// Tokens 0 with a MaxOutput changes only that, in the catalog, which the
// router reads it from — nothing is started again.
type ContextRequest struct {
Tokens int `json:"tokens"`
Slots int `json:"slots,omitempty"`
Tokens int `json:"tokens,omitempty"`
Slots int `json:"slots,omitempty"`
MaxOutput int `json:"max_output,omitempty"`
}
// ContextProfile is what one profile needs before and after.
@@ -37,13 +41,17 @@ type ContextProfile struct {
// ContextChange is what was changed, and the replicas started again on it.
type ContextChange struct {
Model string `json:"model"`
Before int `json:"before"`
After int `json:"after"`
MaxOutput int `json:"max_output"`
Trained int `json:"trained,omitempty"`
Profiles []ContextProfile `json:"profiles"`
Restarted int `json:"restarted"`
Model string `json:"model"`
Before int `json:"before"`
After int `json:"after"`
MaxOutput int `json:"max_output"`
// OutputOnly is a change of the longest answer alone, MaxOutputBefore
// what it was (0 no limit); nothing was started again.
OutputOnly bool `json:"output_only,omitempty"`
MaxOutputBefore int `json:"max_output_before,omitempty"`
Trained int `json:"trained,omitempty"`
Profiles []ContextProfile `json:"profiles"`
Restarted int `json:"restarted"`
// Note is what could not be checked, said once.
Note string `json:"note,omitempty"`
}
@@ -75,8 +83,14 @@ func (s *Server) SetContext(ctx context.Context, name string, req ContextRequest
return ContextChange{}, fmt.Errorf("no model %q in the catalog", name)
case s.deps.Models.Declared(name):
return ContextChange{}, fmt.Errorf("model %q is declared in the cluster's settings: change its context there (llmgw ctl config edit)", name)
case req.MaxOutput < 0:
return ContextChange{}, errors.New("the longest answer is a number of tokens")
case req.Tokens == 0 && req.MaxOutput > 0:
return s.setMaxOutput(ctx, m, req.MaxOutput, by)
case req.Tokens < 256:
return ContextChange{}, errors.New("a context of at least 256 tokens")
case req.MaxOutput > req.Tokens:
return ContextChange{}, fmt.Errorf("an answer of %d tokens does not fit a context of %d", req.MaxOutput, req.Tokens)
case req.Slots < 0 || req.Slots > 64:
return ContextChange{}, errors.New("slots are 1 to 64")
}
@@ -100,6 +114,9 @@ func (s *Server) SetContext(ctx context.Context, name string, req ContextRequest
out.MaxOutput = maxOutput(req.Tokens)
}
out.MaxOutput = min(out.MaxOutput, req.Tokens)
if req.MaxOutput > 0 {
out.MaxOutput = req.MaxOutput
}
if _, err := s.deps.Models.SetContext(ctx, name, req.Tokens, out.MaxOutput, profiles, by); err != nil {
return ContextChange{}, err
}
@@ -121,6 +138,21 @@ func (s *Server) SetContext(ctx context.Context, name string, req ContextRequest
return out, nil
}
// setMaxOutput changes only the longest answer: the catalog's, which the
// router holds a request's max_tokens to. No engine reads it, so no replica
// is started again.
func (s *Server) setMaxOutput(ctx context.Context, m catalog.Model, maxOut int, by string) (ContextChange, error) {
if m.ContextLength > 0 && maxOut > m.ContextLength {
return ContextChange{}, fmt.Errorf("an answer of %d tokens does not fit %s's context of %d: change the context with it",
maxOut, m.Name, m.ContextLength)
}
if _, err := s.deps.Models.SetContext(ctx, m.Name, m.ContextLength, maxOut, m.Profiles, by); err != nil {
return ContextChange{}, err
}
return ContextChange{Model: m.Name, Before: m.ContextLength, After: m.ContextLength, MaxOutput: maxOut,
OutputOnly: true, MaxOutputBefore: m.MaxOutput}, nil
}
// shapeOf reads what sizes a model's context: a GGUF file's header from the
// mirror, else the original weights' config from their repository.
func (s *Server) shapeOf(ctx context.Context, m catalog.Model) (shape, string) {
@@ -174,6 +206,20 @@ func resizeProfile(p catalog.Profile, req ContextRequest, sh shape) (catalog.Pro
case "sglang":
pool = req.Tokens
p.Args = setArg(p.Args, "--context-length", strconv.Itoa(pool))
case "strata":
// Strata's setup takes the context, and writes the engine's start
// script with it at every start; it sizes its KV cache and expert
// cache to the card itself, so there is no card check to make here.
p.Args = setArg(p.Args, "--context", strconv.Itoa(req.Tokens))
change.Args = p.Args
return p, change, nil
case "freetoken":
// Its experts live in RAM and stream to the card, so the weights
// are not what has to fit there, and the card check below would
// refuse what it serves.
p.Args = setArg(p.Args, "--max-seq-len-override", strconv.Itoa(req.Tokens))
change.Args = p.Args
return p, change, nil
default:
return p, change, fmt.Errorf("a %s profile (%s) does not take a context this way", p.Engine, p.Name)
}
@@ -88,3 +88,57 @@ func TestEngineArgumentsAreSetInEitherSpelling(t *testing.T) {
t.Error("a comfyui profile took a context")
}
}
func TestTheLongestAnswerAloneChangesOnlyTheCatalogAndStartsNothing(t *testing.T) {
s, _, models, _, fleet := updSetup(t)
m := models.models["qwen"]
m.ContextLength, m.MaxOutput = 4096, 1024
m.Profiles[0].Args = []string{"--ctx-size", "4096"}
models.models["qwen"] = m
before := fleet.Replicas()
got, err := s.SetContext(context.Background(), "qwen", ContextRequest{MaxOutput: 3000}, "admin")
if err != nil {
t.Fatal(err)
}
if !got.OutputOnly || got.MaxOutput != 3000 || got.MaxOutputBefore != 1024 || got.Restarted != 0 {
t.Fatalf("%+v", got)
}
after := models.models["qwen"]
if after.ContextLength != 4096 || after.MaxOutput != 3000 || !slices.Equal(after.Profiles[0].Args, []string{"--ctx-size", "4096"}) {
t.Errorf("catalog %d/%d, args %v", after.ContextLength, after.MaxOutput, after.Profiles[0].Args)
}
for i, r := range fleet.Replicas() {
if r.ID != before[i].ID {
t.Errorf("%s was started again for a limit no engine reads", before[i].ID)
}
}
// An answer longer than the context is refused, alone or with one.
if _, err := s.SetContext(context.Background(), "qwen", ContextRequest{MaxOutput: 5000}, "admin"); err == nil {
t.Error("an answer longer than the context was accepted")
}
if _, err := s.SetContext(context.Background(), "qwen", ContextRequest{Tokens: 8192, MaxOutput: 9000}, "admin"); err == nil {
t.Error("an answer longer than the new context was accepted")
}
// With a context, the one asked for wins over the one that follows it.
got, err = s.SetContext(context.Background(), "qwen", ContextRequest{Tokens: 8192, MaxOutput: 6000}, "admin")
if err != nil || got.MaxOutput != 6000 || models.models["qwen"].MaxOutput != 6000 {
t.Errorf("%+v %v", got, err)
}
}
func TestStrataTakesItsContextInItsSetupArguments(t *testing.T) {
p := catalog.Profile{Name: "one-card", Engine: "strata", GPUClass: "6000-24g",
Args: []string{"--family", "qwen", "--size", "IQ2_XS"}}
got, change, err := resizeProfile(p, ContextRequest{Tokens: 32768}, shape{kvPerToken: 1 << 20, weights: 40 << 30})
if err != nil {
t.Fatal(err)
}
// Not refused for weights larger than the card: its experts live in RAM.
if !slices.Equal(got.Args, []string{"--family", "qwen", "--size", "IQ2_XS", "--context", "32768"}) || !slices.Equal(change.Args, got.Args) {
t.Errorf("args %v", got.Args)
}
if n := contextOf(catalog.Model{ContextLength: 4096}, got); n != 32768 {
t.Errorf("contextOf %d", n)
}
}
+8
View File
@@ -208,6 +208,14 @@ func contextOf(m catalog.Model, p catalog.Profile) int {
if n := argInt(p.Args, "--context-length"); n > 0 {
return n
}
case "strata":
if n := argInt(p.Args, "--context"); n > 0 {
return n
}
case "freetoken":
if n := argInt(p.Args, "--max-seq-len-override"); n > 0 {
return n
}
}
return m.ContextLength
}
+81 -2
View File
@@ -35,6 +35,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
)
// Registry reads a repository.
@@ -134,6 +135,9 @@ type Request struct {
// Fallbacks adds the next best ways, lower in priority, so a model whose
// first choice is busy runs on another rather than waiting.
Fallbacks bool `json:"fallbacks,omitempty"`
// Engine is the engine the file runs on: "strata" for a family Strata
// runs, "llamacpp" to keep it off Strata; empty, the plan's best.
Engine string `json:"engine,omitempty"`
// Name is the catalog name; the repository suggests one otherwise.
Name string `json:"name,omitempty"`
Aliases []string `json:"aliases,omitempty"`
@@ -409,6 +413,40 @@ func (s *Server) run(j *Job) {
if req.TP > 0 && req.GPUsPerNode == 0 {
req.GPUsPerNode = req.TP
}
// A pack Strata fetches itself: no mirror; its repository is the source.
if _, size, ok := strata.ForFetchedRepo(repo.ID); ok {
plan, err := s.plan(ctx, repo, PlanRequest{Repo: repo.ID})
if err != nil {
s.fail(j, "registry", err)
return
}
set := hf.FileSet{Name: size.Name}
layout, err := chooseLayout(Request{Format: "gguf", Engine: "strata", GPUClass: req.GPUClass}, plan, true, set)
if err != nil {
s.fail(j, "registry", err)
return
}
name := req.Name
if name == "" {
name = hf.ModelName(repo.ID)
}
s.mu.Lock()
j.Model = name
s.mu.Unlock()
if !req.sized {
req.ContextLength = strataContext
}
s.step(j, "registry", Done, fmt.Sprintf("Strata's ready-made %s pack, revision %s; runs on %s",
size.Name, repo.ShortRevision(), layout[0].Label()))
s.step(j, "mirror", Done, fmt.Sprintf("not through the mirror: Strata fetches about %.0f GB onto the node itself", size.DownloadGB))
profiles := profilesFor(layout, req, 0, 0)
profiles[0].StartupTimeout = 4 * time.Hour
s.mu.Lock()
j.Request = req
s.mu.Unlock()
s.addAndStart(ctx, j, repo, set, name, repo.ID, profiles, hardwareExists(layout))
return
}
// The plan: which file suits the hardware, and how it spreads over it.
planCtx := req.CtxPool
if req.Format == "safetensors" {
@@ -488,6 +526,23 @@ func (s *Server) run(j *Job) {
}
// 3. Whether the nodes' engine loads it, and how much memory it needs.
if layout[0].Engine == "strata" {
// Strata prepares it on the node from the directory of its files,
// and keeps the experts in the RAM the plan counted.
if !strings.HasSuffix(source, "/") {
source = strings.TrimSuffix(source, mirror.LastPathElement(source))
}
if !req.sized {
req.ContextLength = strataContext
}
s.step(j, "check", Done, fmt.Sprintf("Strata's %s %s, prepared on the node; %.0f GB of RAM for its experts",
layout[0].StrataFamily, layout[0].StrataSize, layout[0].NeedGB))
s.mu.Lock()
j.Request = req
s.mu.Unlock()
s.addAndStart(ctx, j, repo, set, name, source, profilesFor(layout, req, 0, 0), hardwareExists(layout))
return
}
if req.Format == "safetensors" {
// vLLM reads the model's shape from its config.json, which came
// with the weights.
@@ -566,10 +621,21 @@ func chooseLayout(req Request, plan Plan, planned bool, set hf.FileSet) ([]model
}
var opts []modelplan.Option
for _, o := range plan.Options {
if o.Artifact == artifact {
onStrata := o.Engine == "strata"
if o.Artifact == artifact && (req.Engine == "" || (req.Engine == "strata") == onStrata) {
opts = append(opts, o)
}
}
if req.Engine == "strata" {
// Strata's way only, on the class asked for if one was.
i := slices.IndexFunc(opts, func(o modelplan.Option) bool { return req.GPUClass == "" || o.Class == req.GPUClass })
if i < 0 {
return nil, fmt.Errorf("Strata cannot run %s here: it runs a few families only (`llmgw ctl model plan %s` "+
"says whether this is one), on a node with Strata installed, a card of 12 GB or more and the RAM for its experts",
set.Name, plan.Repo)
}
return []modelplan.Option{opts[i]}, nil
}
var out []modelplan.Option
switch {
case req.CPU || req.GPUClass != "":
@@ -600,7 +666,7 @@ func chooseLayout(req Request, plan Plan, planned bool, set hf.FileSet) ([]model
default:
out = append(out, modelplan.Option{Artifact: artifact, Format: req.Format, Engine: engine, CPU: true})
}
if req.Fallbacks {
if req.Fallbacks && out[0].Engine != "strata" {
shape := func(o modelplan.Option) string {
return fmt.Sprintf("%v/%s/%d/%d", o.CPU, o.Class, o.GPUsPerNode, o.Nodes)
}
@@ -625,6 +691,12 @@ func profilesFor(layout []modelplan.Option, req Request, memGB, cores int) []cat
for i, o := range layout {
p := catalog.Profile{Engine: o.Engine, Weight: 1, Priority: 100 - 10*i, Env: req.Env}
switch {
case o.Engine == "strata":
p.Name = "strata-" + o.Class
p.GPUClass, p.GPUsPerNode = o.Class, 1
p.Args = []string{"--family", o.StrataFamily, "--size", o.StrataSize,
"--context", strconv.Itoa(req.ContextLength), "--vision", "no"}
p.MinMemoryGB = int(o.NeedGB)
case o.CPU:
p.Name = "cpu"
p.Args = llamaArgs(req)
@@ -674,6 +746,10 @@ func llamaArgs(req Request) []string {
return args
}
// strataContext is a Strata model's context when its caller did not say:
// Strata's own default.
const strataContext = 32768
// An embedding model's slots, when its caller did not size them: passages
// of up to a thousand tokens, thirty-two at a time.
const (
@@ -848,6 +924,9 @@ func (s *Server) mirror(ctx context.Context, j *Job, repo hf.Repo, set hf.FileSe
}
resp, err := s.deps.Mirror.Fetch(ctx, mirror.FetchRequest{
Source: repo.DownloadURL("", f.Path), Model: name, Version: version, File: file,
// The mirror deletes a copy that is not what the registry
// published, rather than holding it for the nodes.
SHA256: f.SHA256,
})
// Already there is not a failure: the same commit fetched before.
if err != nil && !strings.Contains(err.Error(), "already") {
+30
View File
@@ -9,6 +9,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
)
// Planning: which of a repository's files suits the cluster's hardware, and
@@ -97,6 +98,23 @@ func (s *Server) Plan(ctx context.Context, req PlanRequest) (Plan, error) {
}
func (s *Server) plan(ctx context.Context, repo hf.Repo, req PlanRequest) (Plan, error) {
// A pack Strata fetches itself: one way, Strata's, whatever its files.
if fam, size, ok := strata.ForFetchedRepo(repo.ID); ok {
art := modelplan.Artifact{Name: size.Name, Format: "strata", Bytes: int64(size.DownloadGB * 1e9), Quant: size.Name}
p := Plan{Repo: repo.ID, Revision: repo.Revision, Format: "strata", Arch: repo.Architecture, License: repo.License,
Context: strataContext, Servable: true, Note: "Strata fetches this pack onto the node itself on its first start"}
p.Options = modelplan.StrataOptions(fam.Name, []modelplan.Artifact{art}, s.deps.Pool())
if len(p.Options) == 0 && fam.MinCC > 0 {
p.Note = fmt.Sprintf("Strata's %s engine needs an NVIDIA card of compute capability %.1f or newer (RTX 30 on); "+
"a node with Strata and such a card, and %d GB of RAM, runs it", fam.Title, fam.MinCC, size.MemoryGB())
}
pa := PlannedArtifact{Artifact: art}
if len(p.Options) > 0 {
pa.Best = &p.Options[0]
}
p.Artifacts = []PlannedArtifact{pa}
return p, nil
}
p := Plan{Repo: repo.ID, Revision: repo.Revision, Format: req.Format, Arch: repo.Architecture,
License: repo.License, Params: repo.Params, Context: req.Context, Gated: repo.Gated.Is(), Servable: true,
Projectors: Projectors(repo)}
@@ -168,6 +186,18 @@ func (s *Server) plan(ctx context.Context, repo hf.Repo, req PlanRequest) (Plan,
if !p.Servable {
p.Options = nil
}
// A family Strata runs: its files on one card with the experts in RAM.
// Strata reads them itself, whether or not llama.cpp could.
if fam, ok := strata.ForRepo(repo.ID); ok && p.Format == "gguf" {
if so := modelplan.StrataOptions(fam.Name, arts, s.deps.Pool()); len(so) > 0 {
p.Options = append(p.Options, so...)
modelplan.Rank(p.Options)
if !p.Servable {
p.Note = strings.TrimSpace(p.Note + " llama.cpp cannot run it (" + p.Reason + "); Strata can.")
p.Servable, p.Reason = true, ""
}
}
}
for _, a := range arts {
pa := PlannedArtifact{Artifact: a}
for i := range p.Options {
@@ -0,0 +1,52 @@
package modelserve
import (
"slices"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
"gitea.mynodes.xyz/ilker/llmgw/internal/modelplan"
)
func TestAStrataWayIsChosenAndMadeAProfile(t *testing.T) {
set := hf.FileSet{Name: "Qwen3.8-Flash-Next-GSQ-RCO-IQ2_XS-00001-of-00002.gguf"}
strataWay := modelplan.Option{Artifact: set.Name, Format: "gguf", Engine: "strata", Class: "6000-24g",
GPUsPerNode: 1, Nodes: 1, NeedGB: 52, HaveGB: 213, Hosts: 1, StrataFamily: "qwen", StrataSize: "IQ2_XS"}
cpuWay := modelplan.Option{Artifact: set.Name, Format: "gguf", Engine: "llamacpp", CPU: true, NeedGB: 75, HaveGB: 213, Hosts: 1}
plan := Plan{Repo: "ISTA-DASLab/Qwen3.8-Flash-Next-GSQ-RCO-GGUF", Options: []modelplan.Option{strataWay, cpuWay}}
got, err := chooseLayout(Request{Format: "gguf", Fallbacks: true}, plan, true, set)
if err != nil || len(got) != 1 || got[0].Engine != "strata" {
t.Fatalf("the plan's best, without llama.cpp fallbacks: %+v %v", got, err)
}
if got, _ := chooseLayout(Request{Format: "gguf", Engine: "llamacpp"}, plan, true, set); len(got) != 1 || !got[0].CPU {
t.Errorf("kept off Strata: %+v", got)
}
if _, err := chooseLayout(Request{Format: "gguf", Engine: "strata", GPUClass: "h100-80g"}, plan, true, set); err == nil {
t.Error("Strata on a class it was not planned for")
}
ps := profilesFor(got, Request{ContextLength: 32768}, 0, 0)
p := ps[0]
if p.Engine != "strata" || p.GPUClass != "6000-24g" || p.GPUsPerNode != 1 || p.MinMemoryGB != 52 ||
!slices.Equal(p.Args, []string{"--family", "qwen", "--size", "IQ2_XS", "--context", "32768", "--vision", "no"}) ||
!strings.HasPrefix(p.Name, "strata-") {
t.Errorf("%+v", p)
}
}
func TestAReadyMadePackIsPlannedOnStrataOnly(t *testing.T) {
s := New(Deps{Pool: func() []modelplan.PoolNode {
return []modelplan.PoolNode{{ID: "cuda01", Class: "6000-24g", GPUMemGB: 24, GPUs: 1, FreeGPUs: 1,
HostMemGB: 251, HostFreeGB: 200, Engines: []string{"strata"}}}
}})
p, err := s.plan(t.Context(), hf.Repo{ID: "Maximilian228/Qwen3.8-Flash-Next-NVIDIA-NVFP4-Strata", Revision: "abc",
Architecture: "strata-ple"}, PlanRequest{})
if err != nil {
t.Fatal(err)
}
if !p.Servable || len(p.Options) != 1 || p.Options[0].StrataFamily != "nvfp4" || p.Options[0].StrataSize != "NVFP4-NVIDIA" {
t.Errorf("%+v", p)
}
}
+57
View File
@@ -27,6 +27,7 @@ import (
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
)
// Store is the persistence this needs.
@@ -51,6 +52,8 @@ type Service struct {
// check refuses a model that cannot run before it is added; see
// SetCheck.
check func(context.Context, catalog.Model) error
// registry pins what is added from it to a commit; nil pins nothing.
registry Registry
// federated are the models federated clusters offer; see SetFederated.
federated func() []catalog.Model
@@ -127,12 +130,31 @@ func (s *Service) add(ctx context.Context, m catalog.Model, by string, onlyNew b
// fits everywhere as far as placement can tell — however many of it
// there already are. Required of what is added now; models stored before
// this still load, and say what they are missing.
m.Profiles = slices.Clone(m.Profiles)
for i := range m.Profiles {
// Strata keeps a model's experts in the host's memory, tens of
// gigabytes of it: placed as if it took none, two replicas, or one
// beside a CPU model, filled a node until one was killed. What its
// size takes, from Strata's table, unless the model says.
if p := &m.Profiles[i]; p.Engine == "strata" && p.MinMemoryGB <= 0 {
size, err := strata.SizeOf(strata.ArgValue(p.Args, "--family"), strata.ArgValue(p.Args, "--size"))
if err != nil {
return fmt.Errorf("model %q profile %q: %w", m.Name, p.Name, err)
}
p.MinMemoryGB = size.MemoryGB()
}
}
for _, p := range m.Profiles {
if p.CPUOnly() && p.MinMemoryGB <= 0 {
return fmt.Errorf("model %q profile %q runs without a GPU and declares no memory (min_memory_gb): "+
"placement could not tell when a node is full", m.Name, p.Name)
}
}
if s.registry != nil {
if err := pin(ctx, s.registry, &m); err != nil {
return err
}
}
if s.check != nil {
if err := s.check(ctx, m); err != nil {
return err
@@ -228,6 +250,8 @@ func (s *Service) Remove(ctx context.Context, name string) error {
func (s *Service) Scale(ctx context.Context, name string, sc catalog.Scaling, by string) (catalog.Model, error) {
final, err := s.update(ctx, name, by, "change its scaling", func(m *catalog.Model) error {
m.Scaling = sc
// A number set is what it runs now: a stopped model is started by it.
m.Stopped = nil
return nil
})
if err == nil {
@@ -237,6 +261,39 @@ func (s *Service) Scale(ctx context.Context, name string, sc catalog.Scaling, by
return final, err
}
// Stop takes a model out of service and keeps it: none of its replicas run,
// and what it was scaled to is kept for Start.
func (s *Service) Stop(ctx context.Context, name, by string) (catalog.Model, error) {
final, err := s.update(ctx, name, by, "stop it", func(m *catalog.Model) error {
if m.Stopped != nil {
return fmt.Errorf("model %q is already stopped; llmgw ctl model start %s runs it again", name, name)
}
was := m.Scaling
m.Stopped = &was
m.Scaling = catalog.Scaling{IdleTimeout: was.IdleTimeout}
return nil
})
if err == nil {
s.log.Info("model stopped", "model", name, "by", by)
}
return final, err
}
// Start runs a stopped model again, scaled as it was when it was stopped.
func (s *Service) Start(ctx context.Context, name, by string) (catalog.Model, error) {
final, err := s.update(ctx, name, by, "start it", func(m *catalog.Model) error {
if m.Stopped == nil {
return fmt.Errorf("model %q is not stopped; llmgw ctl scale changes how many replicas it runs", name)
}
m.Scaling, m.Stopped = *m.Stopped, nil
return nil
})
if err == nil {
s.log.Info("model started", "model", name, "min", final.Scaling.Min, "max", final.Scaling.Max, "by", by)
}
return final, err
}
// Resources sets what a model's profile reserves of a node: host memory, and
// the CPU cores a replica without a GPU computes on. An empty profile name
// sets every profile.
@@ -400,3 +400,61 @@ func TestAddNewLeavesAModelAddedMeanwhile(t *testing.T) {
t.Errorf("a new model: %v", err)
}
}
func TestAStoppedModelRunsNoneAndStartsAsItWas(t *testing.T) {
s, _, rec := newService(t)
if err := s.Add(t.Context(), model("added"), "ilker"); err != nil {
t.Fatal(err)
}
if _, err := s.Scale(t.Context(), "added", catalog.Scaling{Min: 2, Max: 4, IdleTimeout: 20 * time.Minute}, "ilker"); err != nil {
t.Fatal(err)
}
got, err := s.Stop(t.Context(), "added", "ilker")
if err != nil {
t.Fatal(err)
}
if got.Scaling.Min != 0 || got.Scaling.Max != 0 || got.Stopped == nil || got.Stopped.Min != 2 {
t.Fatalf("stopped: %+v %+v", got.Scaling, got.Stopped)
}
if m, _ := rec.last.Resolve("added"); m.Scaling.Max != 0 || m.Scaling.OnDemand() {
t.Errorf("the catalog in force runs it: %+v", m.Scaling)
}
if _, err := s.Stop(t.Context(), "added", "ilker"); err == nil {
t.Error("stopped twice")
}
got, err = s.Start(t.Context(), "added", "ilker")
if err != nil {
t.Fatal(err)
}
if got.Scaling.Min != 2 || got.Scaling.Max != 4 || got.Scaling.IdleTimeout != 20*time.Minute || got.Stopped != nil {
t.Errorf("started: %+v %+v", got.Scaling, got.Stopped)
}
if _, err := s.Start(t.Context(), "added", "ilker"); err == nil {
t.Error("started what was not stopped")
}
// A scale while stopped starts it at that number.
_, _ = s.Stop(t.Context(), "added", "ilker")
if got, _ := s.Scale(t.Context(), "added", catalog.Scaling{Min: 1}, "ilker"); got.Stopped != nil {
t.Error("still stopped after a scale")
}
}
func TestAStrataModelReservesTheMemoryItsExpertsTake(t *testing.T) {
s, _, rec := newService(t)
m := model("big")
m.Source = "http://mirror:8081/big/v1/"
m.Profiles = []catalog.Profile{{Name: "gpu", Engine: "strata", GPUClass: "6000-24g", GPUsPerNode: 1,
Args: []string{"--family", "qwen", "--size", "IQ2_XS"}}}
if err := s.Add(t.Context(), m, "ilker"); err != nil {
t.Fatal(err)
}
got, _ := rec.last.Resolve("big")
if got.Profiles[0].MinMemoryGB != 52 {
t.Errorf("IQ2_XS reserves %d GB, want Strata's 48 and 4 for the engine", got.Profiles[0].MinMemoryGB)
}
m.Name = "big2"
m.Profiles[0].Args = []string{"--family", "qwen", "--size", "IQ9"}
if err := s.Add(t.Context(), m, "ilker"); err == nil || !strings.Contains(err.Error(), "comes in") {
t.Errorf("an unknown size: %v", err)
}
}
+133
View File
@@ -0,0 +1,133 @@
package modelsvc
import (
"context"
"fmt"
"regexp"
"slices"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/hf"
)
// A model added with weights from the registry is pinned to the commit they
// were at: a file named by hf: or by a resolve URL on a branch becomes that
// commit's URL, and a repository an engine downloads itself is given
// --revision. The registry's sha256 of each file goes in the model's origin,
// for the nodes to check what they fetch against. Without it, a new upload
// to the repository reached the nodes that fetched after it, unseen, beside
// nodes that still had the old one.
// Registry is where a repository's commit and files are looked up.
type Registry interface {
RepoAt(ctx context.Context, id, rev string) (hf.Repo, error)
Base() string
}
// SetRegistry has every model added from now on pinned against it.
func (s *Service) SetRegistry(r Registry) { s.registry = r }
// engineDownloads are the engines that download a repository named by its id
// themselves, and take --revision.
var engineDownloads = map[string]bool{"vllm": true, "sglang": true}
var repoID = regexp.MustCompile(`^[A-Za-z0-9][\w.-]*/[\w.-]+$`)
// pin pins a model's registry sources, in place. One already given an origin
// — served through the mirror, which records it — is left as it is.
func pin(ctx context.Context, reg Registry, m *catalog.Model) error {
if m.Origin != nil {
return nil
}
repos := map[string]hf.Repo{}
lookup := func(id, rev string) (hf.Repo, error) {
key := id + "@" + rev
if r, ok := repos[key]; ok {
return r, nil
}
r, err := reg.RepoAt(ctx, id, rev)
if err != nil {
return r, fmt.Errorf("model %q: looking up %s in the registry, to pin it to a commit: %w", m.Name, id, err)
}
repos[key] = r
return r, nil
}
record := func(r hf.Repo, files ...hf.File) {
if m.Origin == nil {
m.Origin = &catalog.Origin{Repo: r.ID, Revision: r.Revision}
}
if m.Origin.Repo != r.ID || m.Origin.Revision != r.Revision {
return // one origin a model; a second repository is pinned, not recorded
}
for _, f := range files {
m.Origin.Files = append(m.Origin.Files, catalog.OriginFile{Path: f.Path, SHA256: f.SHA256, Bytes: f.Bytes})
}
}
// One file: its commit's URL.
pinned := map[string]string{}
for _, src := range m.WeightSources() {
id, rev, file, ok := hf.FileSource(src, reg.Base())
if !ok {
continue
}
r, err := lookup(id, rev)
if err != nil {
return err
}
i := slices.IndexFunc(r.Files, func(f hf.File) bool { return f.Path == file })
if i < 0 {
return fmt.Errorf("model %q: %s has no file %s at %s", m.Name, id, file, r.ShortRevision())
}
pinned[src] = r.DownloadURL(reg.Base(), file)
record(r, r.Files[i])
}
if to, ok := pinned[m.Source]; ok {
m.Source = to
}
for i := range m.Profiles {
if to, ok := pinned[m.Profiles[i].Source]; ok {
m.Profiles[i].Source = to
}
}
// A repository an engine downloads: --revision.
for i := range m.Profiles {
p := &m.Profiles[i]
src := m.SourceOf(*p)
if !engineDownloads[p.Engine] || !repoID.MatchString(src) {
continue
}
r, err := lookup(src, argValue(p.Args, "--revision"))
if err != nil {
return err
}
if argValue(p.Args, "--revision") == "" {
p.Args = append(slices.Clone(p.Args), "--revision", r.Revision)
}
var weights []hf.File
for _, f := range r.Files {
if f.IsWeights() {
weights = append(weights, f)
}
}
if m.Origin == nil || m.Origin.Repo != r.ID || len(m.Origin.Files) == 0 {
record(r, weights...)
}
}
return nil
}
// argValue is a flag's value in arguments, "" when absent.
func argValue(args []string, flag string) string {
for i, a := range args {
if a == flag && i+1 < len(args) {
return args[i+1]
}
if v, ok := strings.CutPrefix(a, flag+"="); ok {
return v
}
}
return ""
}
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more