Compare commits
No files matched your search
@@ -34,7 +34,11 @@ jobs:
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test with the race detector
|
||||
run: go test -race ./...
|
||||
# Under the job's limit, so a package that hangs panics with the
|
||||
# test it was in and where every goroutine waited, rather than the
|
||||
# job ending at its deadline with nothing said: from 2026-10-15 every
|
||||
# run did, printing nothing after internal/release.
|
||||
run: go test -race -timeout 12m ./...
|
||||
|
||||
- name: Build
|
||||
run: go build ./...
|
||||
|
||||
@@ -9,3 +9,5 @@ coverage.out
|
||||
/terraform-provider-llmgw/terraform-provider-llmgw
|
||||
/terraform-provider-llmgw/examples/.terraform*
|
||||
/terraform-provider-llmgw/examples/terraform.tfstate*
|
||||
# What a development server run from the checkout writes beside it.
|
||||
/secrets/
|
||||
+347
-4
@@ -15,6 +15,66 @@ Conventions this codebase keeps, which every item here must keep too:
|
||||
- `go test ./...`, `go vet ./...` and `govulncheck ./...` pass. No release is
|
||||
cut and nothing is deployed to the PoC cluster without being told to.
|
||||
|
||||
## Open, in order (2026-10-20)
|
||||
|
||||
What is left, most needed first. The detail is in the item each names.
|
||||
|
||||
**Now: what goes wrong where it is installed.**
|
||||
1. ~~**J8**~~: done 2026-10-20. `ctl guard judge check`, and init times the judge. Left of **J3**: its load test
|
||||
against judges on the control plane, which was stopped half way. Judges on a 4-core node of their own refused
|
||||
0/60 at 2 at once, 13/60 at 4 and 42/60 at 10.
|
||||
2. ~~**I2 P2**~~: done 2026-10-20: the guardrails both ways, a rate per caller and server, a breaker. A portal page
|
||||
for the servers is left, with P3 (stdio, per-person OAuth).
|
||||
3. **K**: fixes from the fabric test the operator runs with docs/large-models.md.
|
||||
|
||||
**Soon: the customer's experience, and HA.**
|
||||
4. ~~**C24**~~: done 2026-10-21. The store's, catalog's and mirror's errors, percent signs, footer times and stale
|
||||
forms are in the page's language. Left: the /docs documents, and a language kept per person (cookie only today).
|
||||
5. **J4**: the model mirror on more than one machine. 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)
|
||||
|
||||
**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.
|
||||
|
||||
**Governance.**
|
||||
7. **C1**: a tenant-admin role, and forms hidden from viewers.
|
||||
8. **Audit before and after**: what a change changed, and changes made with the CLI recorded too.
|
||||
|
||||
**When there is room.**
|
||||
9. ~~**I1**~~: done 2026-10-25 (in memory, not pgvector); left: the 40-rewordings run on the PoC.
|
||||
10. **H1**: tried on the PoC.
|
||||
11. **C4**: Parquet, and a scheduled push for usage export.
|
||||
|
||||
**When needed.**
|
||||
12. **H5**: licences in video and audio. 13. **H3**: replicas shared, the catalog side. 14. **J7**: nginx in front
|
||||
of several control planes. 15. **J6**: rate limits per process (a note).
|
||||
|
||||
**Done since 2026-10-16:**
|
||||
- J1, J5, I3, I4, J2.
|
||||
- Active Directory and Entra ID, tried against a Samba DC, with the console's Sign-in screen.
|
||||
- J3's code.
|
||||
- K's code and docs/large-models.md.
|
||||
- I2 P1 and the cluster's own read-only MCP server (llmgw:admin).
|
||||
- The node console's Overview; a node's page showing its version and the models on each GPU.
|
||||
|
||||
**Not code, the operator's to decide or do:**
|
||||
- Where Alertmanager sends (its receiver is "none"): with a judge down, nobody hears.
|
||||
- The fabric test for K.
|
||||
- The PoC runs a dev build of main (v0.15.0-7-ga583359) on its control planes and nodes, the mirror v0.15.0.
|
||||
|
||||
---
|
||||
|
||||
## A. Security — do first
|
||||
@@ -1429,6 +1489,14 @@ console show and add them.
|
||||
- Done 2026-10-15: llmgw's `/mcp` with picture, 3D, VLM and workflow tools,
|
||||
so a client needs no server of its own (asked by the game team); the
|
||||
shared ComfyUI replica (H3's placement side).
|
||||
- Done 2026-10-16 (v0.14.6, v0.14.7): setup lists every part with what it
|
||||
is and its settings; three machines each run the control plane, with a
|
||||
floating address (VRRP id from the address); a lost store primary costs no
|
||||
request (3 s connect per member, keepalive and TCP_USER_TIMEOUT, Patroni
|
||||
ttl 20, a standby leaves the lease alone a round after the store returns);
|
||||
a new leader waits for its judge within the budget; a judge is asked again
|
||||
on another machine at half its budget; init sizes the judge (2 GB, no
|
||||
prompt cache, two cores, one judge on up to four cores on one machine).
|
||||
|
||||
## I. What other gateways do that llmgw does not (written 2026-10-16)
|
||||
|
||||
@@ -1442,7 +1510,13 @@ Azure/Anthropic/Bedrock/Vertex adapters, request hooks a customer plugs in,
|
||||
routing by what a request holds. Not taken: a realtime (WebSocket audio)
|
||||
API, prompt management, an `npx` install.
|
||||
|
||||
### I1. Answers to similar questions, not only the same one (M)
|
||||
### I1. Answers to similar questions, not only the same one (M) — done 2026-10-25
|
||||
`keys cache --similar`, `router.cache.embedding_model`, `internal/router/similar.go`. Changed
|
||||
from the plan: the vectors are in the gateway's memory beside the exact answers, not a pgvector
|
||||
table. The exact cache keeps answer text out of the store on purpose (data.md), and a table of
|
||||
answers would undo that; it also works on SQLite. The usage row marks a similar hit as a hit
|
||||
(no column of its own). Open: the 40-rewordings and eval-suite runs on the PoC.
|
||||
The original item follows.
|
||||
The answer cache (`internal/router/cache.go`, C17) answers a request whose
|
||||
body is the same byte for byte. A help desk asked "how do I reset my
|
||||
password" in forty wordings asks the model forty times.
|
||||
@@ -1466,6 +1540,14 @@ password" in forty wordings asks the model forty times.
|
||||
point of the run with it off.
|
||||
|
||||
### I2. MCP gateway: the customer's tool servers behind llmgw (L)
|
||||
P1 done 2026-10-20: HTTP servers registered (`ctl mcp`, `/api/v1/mcp/servers`,
|
||||
store `mcp_servers`), tools as `server.tool` in `/mcp`, `allowed_tools`
|
||||
grants in the tree (deny by default), credentials in the store or by
|
||||
reference, the caller named in `X-Llmgw-*` headers, time and size bounds,
|
||||
progress passed on, audit (Source `mcp`) and metrics. Left: P2, the
|
||||
guardrails both ways, a per-tool rate limit, a breaker, and a portal page;
|
||||
P3, stdio servers and per-person OAuth. The "done when" with Claude Code
|
||||
against a real server is still to try.
|
||||
`/mcp` serves llmgw's own tools. An agent inside a company also wants Jira,
|
||||
the file server, a database — each an MCP server, each today configured
|
||||
in every client with its own credentials and nobody seeing what was called.
|
||||
@@ -1485,7 +1567,16 @@ in every client with its own credentials and nobody seeing what was called.
|
||||
through it, a key without the grant does not see the server's tools, and
|
||||
every call is in `ctl audit`.
|
||||
|
||||
### I3. What llmgw adds to a request, measured (S)
|
||||
### I3. What llmgw adds to a request, measured (S) — done 2026-10-16
|
||||
`llmgw ctl bench overhead` (`internal/cli/bench_overhead.go`): an instant
|
||||
engine and the gateway in one process, four modes (direct, bare, auth, auth
|
||||
and guard patterns) at rising rates. On an Apple M5 the gateway added
|
||||
0.05–0.42 ms at p50 and at most 0.49 ms at p99, in the README.
|
||||
`TestTheGatewaysOverheadIsMeasured` runs a second of it in CI with no bound
|
||||
asserted. Left: the CPU figure is rough with everything in one process; a
|
||||
gateway in a process of its own would measure it properly. The original
|
||||
item follows.
|
||||
|
||||
Bifrost's headline is "11 µs at 5k RPS" (its own figure, and its own pages
|
||||
disagree with it). llmgw has none, so any question about its overhead is
|
||||
answered with a guess.
|
||||
@@ -1500,7 +1591,13 @@ answered with a guess.
|
||||
- Done when: the README states the overhead, with how it was measured, and
|
||||
CI runs the short version so a change that doubles it is seen.
|
||||
|
||||
### I4. Several keys for one outside provider (S)
|
||||
### I4. Several keys for one outside provider (S) — done 2026-10-17
|
||||
`external.api_key_files` (`catalog.ExternalKey`: file, weight), one endpoint
|
||||
each in `control.externalEndpoints`, 401 retried on another key and
|
||||
`external_key_refused` notified (`router.keyRefused`). With one key a 401 is
|
||||
still the client's. `TestAKeyAtItsLimitOrRefusedIsPassedOverForAnother`. The
|
||||
original item follows.
|
||||
|
||||
An external model has one `api_key_file` (`internal/control/control.go`,
|
||||
where external endpoints are made). A provider's limit is per key; one key
|
||||
answering 429 makes the whole fallback answer 429.
|
||||
@@ -1513,9 +1610,255 @@ answering 429 makes the whole fallback answer 429.
|
||||
- Done when: with two keys and one returning 429, every request is answered
|
||||
by the other, and the console shows the one breaker open.
|
||||
|
||||
## J. Found testing HA and single-machine installs (written 2026-10-16)
|
||||
|
||||
Three VMs installed as HA from the setup screens, a fourth as a judge node,
|
||||
a fifth as a single 4-core, 8 GB machine; machines powered off under load.
|
||||
|
||||
### J1. A restart refused for room another restart was giving back (S) — done 2026-10-16
|
||||
Not a wait for ever, as first written: on the 4-core VM the judge's restart
|
||||
was *refused* ("fits on no node even with this replica stopped") because the
|
||||
starter, restarted at the same time to reserve fewer cores, still held its
|
||||
old ones. Once refused, nothing tried again, so the judges kept their old
|
||||
figures. Two fixes in `internal/control/reconciler`:
|
||||
- a restart whose replacement fits once other restarts under way have gone
|
||||
(queued, retiring or draining) waits for them, up to DrainGrace, instead
|
||||
of being refused (`fitsAfterRestarts`, restart.go);
|
||||
- a drained replica counts a retiring one that still answers as serving, so
|
||||
one restart after another no longer leaves the first's drained replica,
|
||||
and the room it holds, waiting out its grace (reconciler.go).
|
||||
`TestARestartWaitsForRoomAnotherRestartWillGiveBack` is the 4-core case.
|
||||
|
||||
### J2. A judge the leader adds is sized as init's is (S)
|
||||
When a policy names a judge the catalog lacks, the leader serves it itself
|
||||
(`internal/cli/judgekeeper.go` via `knownJudge.serveRequest` in
|
||||
`judges_known.go`, through modelserve). That judge reserves 3 GB (the
|
||||
weights, an 8192-token KV cache and a fixed gigabyte), no cores, and runs
|
||||
with llama.cpp's prompt cache, which grew one to 3.2 GB. init's judge
|
||||
(`guardModel` in `internal/cli/serverinit_cluster.go`) reserves 2 GB and
|
||||
its cores and runs with `--cache-ram 0`.
|
||||
- `modelserve` gives a guard model `--cache-ram 0`, and the keeper's
|
||||
request the same memory and cores rule as `cpuPlan`.
|
||||
- Done when: a judge removed from the catalog comes back with the same
|
||||
figures init would have given it.
|
||||
|
||||
### J3. HA setup puts the judges on nodes of their own (S)
|
||||
Under load on a 4-core control plane that was also a node, 38 of 60
|
||||
requests were refused for want of a judgment. With the judges on a node of
|
||||
their own (4 cores, 32 GB, two cores each) the same test refused 2.
|
||||
- The HA setup offers a judge node or two: machines given like the store's,
|
||||
added as nodes with a label (`llmgw.role: judge`), and the judge's
|
||||
profile selects that label.
|
||||
- Done when: the setup installs the judges on the machines given for them,
|
||||
and the load test above refuses no more than it did by hand.
|
||||
|
||||
### J4. The model mirror on more than one machine (M)
|
||||
In an HA install the mirror stays on the first machine. While it is down,
|
||||
models the nodes already have keep serving and new ones wait for it.
|
||||
- A second mirror kept in step (each machine of the store, or one more), the
|
||||
control planes and nodes given both, the first that answers used.
|
||||
- Done when: with the first machine powered off, a model added then is
|
||||
fetched and served.
|
||||
|
||||
### J5. An upgrade corrects an earlier install's judge (S) — done 2026-10-16
|
||||
Built in the judge keeper (`internal/cli/judgekeeper.go`, `refit`), on the
|
||||
leader: a judge whose entry is exactly what init before v0.14.7 wrote is
|
||||
given 2 GB, `--cache-ram 0` and cores by init's rule (`refitCores`: up to
|
||||
four for one judge, two each for more), through `modelsvc.SetProfiles`,
|
||||
and its ready replicas are restarted (after J1). Its replica count is left
|
||||
as it is. One that differs is left alone and said once in the log. The
|
||||
original item follows.
|
||||
|
||||
v0.14.6 and earlier installed two judges reserving no cores or one, with
|
||||
2 or 4 GB and the prompt cache on. On fewer than eight cores every judgment
|
||||
missed its 5 s budget and every request was refused. v0.14.7's notes say
|
||||
how to fix it by hand (`ctl model resources`, then `ctl restart`).
|
||||
- On upgrade, or on `server init` run again, a judge whose profile is still
|
||||
exactly what an earlier init wrote is moved to today's figures and
|
||||
restarted (after J1). One changed by hand is left alone and named.
|
||||
- Done when: a v0.14.6 single-machine install upgraded to it judges within
|
||||
budget without anything typed.
|
||||
|
||||
### J6. Rate limits per process (note)
|
||||
Rate limits are counted in each control plane's memory. A failover starts
|
||||
every key's minute afresh. Documented in install.md; worth a shared count in
|
||||
the store only if a customer's limits must hold across a failover.
|
||||
|
||||
### J7. nginx in front of several control planes (S)
|
||||
`deploy/proxy/Caddyfile` sends to whichever control plane answers `/leader`.
|
||||
nginx's open-source build has no active health checks, so its `upstream`
|
||||
block would send to standbys and to a lost leader alike.
|
||||
- nginx.conf: the floating address as its one upstream, and why; or the
|
||||
`max_fails` passive approach with what it costs.
|
||||
|
||||
### J8. A judge budget that fits the machine (S)
|
||||
A judgment takes 2.3 s on four cores and 4.2 on two, against the 5 s
|
||||
default: two cores are close to it, and a busy machine passes it.
|
||||
- At install, after the judge is up, time three judgments. If the slowest is
|
||||
more than half the budget, say so in the plan's closing text with the
|
||||
choices (more cores, a GPU judge, a longer `latency_budget`, `fail:
|
||||
open`). The budget itself is not changed without being asked.
|
||||
- Done when: an install on a machine too slow for its judge says so before
|
||||
its first refused request.
|
||||
|
||||
## L. Taken from Kong's AI Gateway (written 2026-10-25)
|
||||
|
||||
From a look at Kong AI Gateway 3.9–3.16 and 2.x. Kong's open source stops at
|
||||
3.9, and only six of its AI plugins are open (proxy, prompt template,
|
||||
decorator, regex prompt guard, request and response transformer); the rest
|
||||
is Enterprise or Konnect. So nothing of Kong is embedded: the two meet over
|
||||
plain OpenAI/Anthropic HTTP, and these are the ideas worth taking. Not taken:
|
||||
prompt compression (Kong ships it as a private LLMLingua sidecar, its benefit
|
||||
unshown), prompt templates and decorators (no one has asked), Konnect's
|
||||
entity model. MCP items are llmgw-80's (I2); they are listed for it.
|
||||
|
||||
### L1. Traces in the OpenTelemetry GenAI conventions (S) — done 2026-10-25
|
||||
`gen_ai.*` on the `inference` and `upstream` spans (`internal/router/trace.go`):
|
||||
operation, request and response model, max_tokens, temperature, top_p, the
|
||||
session as conversation id, input/output/cached tokens, the engine as
|
||||
provider. Never prompt or answer text (`TestTracesSpeakTheGenAIConventionsAndHoldNoText`).
|
||||
Prometheus already counted tokens by direction, cached apart. Left: the MCP
|
||||
calls' spans as `gen_ai.tool.*`, once /mcp is traced at all.
|
||||
|
||||
### L2. Redacted personal data put back in the answer (S) — done 2026-10-25
|
||||
`restore: true` (`guard.Rules.Restore`), `Verdict.Restore`, `internal/router/restore.go`
|
||||
ahead of the output filter. The original item follows.
|
||||
The guardrails rewrite personal data as `[KIND_n]` (`internal/guard/gate.go`,
|
||||
`placeholders`), the same value always the same placeholder. A model that
|
||||
answers "Dear [PERSON_1]" leaves it so. Kong's sanitizer restores.
|
||||
- Per policy, `restore: true`: the placeholders of one request kept in memory
|
||||
for its life, and swapped back in the answer, streamed too (a placeholder
|
||||
split across two chunks held until it is whole).
|
||||
- Only into the answer to the request that held them; never stored, never in
|
||||
usage rows or traces.
|
||||
- Done when: "write to Ayşe Yılmaz at ayse@example.com" through an enforce
|
||||
policy reaches the model as placeholders and the client reads the real name
|
||||
and address back.
|
||||
|
||||
### L3. A guardrail that is someone else's service (S) — done 2026-10-25
|
||||
`guard.Service` (`internal/guard/service.go`), policy `services`, asked
|
||||
before the judge on input and once on the whole answer. The original item follows.
|
||||
Customers may be required to use one: Azure Content Safety, Lakera, NeMo
|
||||
Guardrails, an in-house classifier. Kong's AI Custom Guardrail is a
|
||||
templated HTTP call with an expression for "block".
|
||||
- A detector kind `http`: URL, headers from secret files, a body template
|
||||
with the text, a JSON path that says blocked and one that says why;
|
||||
timeout and `fail` as the judge's. Checked on input, output or both.
|
||||
- Done when: a policy with an `http` detector against a test service blocks
|
||||
what it says to, and a service that does not answer is treated as `fail`
|
||||
says.
|
||||
|
||||
### L4. Routing by latency and by cost, and failover by priority (M) — latency done 2026-10-25
|
||||
`endpoint.Registry.ObserveTTFT`, `scheduler.Latency()` in `chat`. Failover in tiers is
|
||||
what `fallbacks` already does (local, then an outside model when no room). Left:
|
||||
routing by cost between several outside providers. The original item follows.
|
||||
The scorers (`internal/router/scheduler/scorers.go`) know sessions, queues
|
||||
and KV cache. Kong also balances by measured latency, by tokens or cost
|
||||
used, and in priority tiers.
|
||||
- A `latency` scorer: a moving average of time to first token per endpoint.
|
||||
- Fallbacks in tiers: local replicas first, then an outside provider, with
|
||||
the tier said in the usage row.
|
||||
- Done when: with two replicas of one model, one on a slower card, `latency`
|
||||
sends most of the traffic to the faster and the slower still gets some.
|
||||
|
||||
### L5. Prices by kind and by cache (S–M) — done 2026-10-25
|
||||
`cached_per_1k`, charged on the answer's `cached_tokens`. A budget in money was already
|
||||
there (`keys limits --budget`). Not done: cache-write and audio/video token prices —
|
||||
no engine llmgw runs reports them, and no provider it reaches is billed by them yet.
|
||||
The original item follows.
|
||||
`catalog` prices are per million input/output tokens and per picture/mesh.
|
||||
Kong prices cached reads and cache writes apart, and audio, image and video
|
||||
tokens apart, and limits by cost.
|
||||
- Price fields for cached input and by modality; the usage row's cost from
|
||||
them; a budget limit in money as well as tokens (`keys limits --cost`).
|
||||
- Done when: a request with 90% cached prompt costs what the cached rate says.
|
||||
|
||||
### L6. REST APIs as MCP tools (M) — for llmgw-80, beside I2
|
||||
Kong turns OpenAPI operations into MCP tools. A company with an internal
|
||||
REST API and no MCP server could offer it to agents through `/mcp`:
|
||||
`llmgw ctl mcp add hr --openapi https://hr.internal/openapi.json`, each
|
||||
operation a tool, under the same grants, audit, guardrails and rate as I2.
|
||||
|
||||
### L7. MCP sign-in by the MCP spec (M) — for llmgw-80, part of I2 P3
|
||||
Kong's MCP OAuth2: llmgw's `/mcp` as an OAuth 2.1 resource server (protected
|
||||
resource metadata, tokens checked by JWKS against the company's identity
|
||||
provider), and a token exchanged for a short-lived one per upstream server
|
||||
rather than one long-lived secret.
|
||||
|
||||
### L8. A2A (S, when asked)
|
||||
Agent-to-agent traffic through the gateway: agent cards' URLs rewritten to
|
||||
llmgw's, calls audited and traced. The standard is young; wait for a
|
||||
customer.
|
||||
|
||||
### L9. llmgw behind Kong, written down (S) — done 2026-10-25
|
||||
docs/kong.md, tried with kong:3.9: plain route and ai-proxy, keys passed on;
|
||||
Kong 3.9 counts no completion tokens of a stream. The original item follows.
|
||||
A customer that already runs Kong puts llmgw behind it: `ai-proxy` with
|
||||
`provider: openai` and `upstream_url` at llmgw (3.9, open source), or a plain
|
||||
route. A page in docs/ with the configuration, tried against Kong 3.9 in a
|
||||
container, and what Kong's token accounting sees.
|
||||
|
||||
## M. Models from Hugging Face we can trust (written 2026-10-25)
|
||||
|
||||
The user asked how we know a model fetched from Hugging Face is safe. Two
|
||||
questions: whether its files can run code on our machines, which we can
|
||||
close, and whether the model itself was trained to misbehave, which no scan
|
||||
proves — only who published it, an eval before it serves, and the
|
||||
guardrails narrow that. What existed: `--trust-remote-code` refused, no
|
||||
repository Python fetched, ComfyUI nodes pinned to a commit, engines
|
||||
hash-pinned and run as `llmgw-engine`, mirror versions pinned to a commit
|
||||
and never overwritten. What did not: everything below.
|
||||
|
||||
### M1. Weights in a pickle format refused (S) — done 2026-10-25
|
||||
`.bin`, `.pt`, `.pth`, `.ckpt`, `.pkl` run code as they are loaded, which is
|
||||
how the malicious models found on Hugging Face work. The node refuses such a
|
||||
source, or a mirror set holding one, before fetching anything; a model being
|
||||
added or given such a source is refused; vLLM and SGLang downloading a model
|
||||
by its id are given `--load-format safetensors`. The explorer hides a repository with neither GGUF nor safetensors
|
||||
(under "runs here", ticked by default) and does not offer a repository's
|
||||
pickle files. `allow_pickle: true` on a model lets it through, on purpose. On the
|
||||
PoC, `triposr` (`hf:stabilityai/TripoSR/model.ckpt`) is the one model that
|
||||
needs it.
|
||||
|
||||
### M2. Every download pinned and checked against Hugging Face (M)
|
||||
- `hf:org/repo/file` follows `main` and is checked by nothing; a bare
|
||||
`org/repo` given to vLLM is downloaded by vLLM, of whatever `main` is.
|
||||
Both: resolve to the commit when the model is added, keep it in the
|
||||
catalog's origin, and fetch that commit only (`--revision` for vLLM).
|
||||
- The sha256 Hugging Face publishes for each LFS file compared on every
|
||||
path, and a file that differs removed: `mirror add`, the manager, the
|
||||
node's `hf:` and http sources, mirror set files with no sha recorded.
|
||||
- Done when: a file changed on Hugging Face after a model was added is
|
||||
refused by the node with both checksums named.
|
||||
|
||||
### M3. Hugging Face's own scan (S)
|
||||
Hugging Face scans every file (pickle imports, ClamAV, partners' scanners)
|
||||
and marks a file unsafe. Ask for it with the repository listing; refuse an
|
||||
unsafe file, show the result on the mirror page and in the catalog's origin.
|
||||
The API field has to be confirmed first.
|
||||
|
||||
### M4. Publishers we trust, and the rest asked about (S)
|
||||
An allow-list of organisations in the cluster's settings (Qwen, meta-llama,
|
||||
mistralai, google, microsoft, …). A model from anyone else is added only by
|
||||
an admin who says so, and who said it is kept in the origin. The PoC already
|
||||
serves four community models whose training nobody here checked.
|
||||
|
||||
### M5. Engines without the internet (M)
|
||||
The firewall is inbound only, so an engine that was made to run code can
|
||||
send what it reads anywhere. With weights from the mirror the engines need
|
||||
no egress: an nft output rule for the engine user (allow the mirror, the
|
||||
cluster, loopback), systemd hardening of the agent's engines
|
||||
(`NoNewPrivileges`, `ProtectSystem`, private /tmp). Agent itself still root.
|
||||
|
||||
### M6. An eval before a new model serves (S)
|
||||
`eval` and `canary` exist; make a new model from an untrusted publisher
|
||||
(M4) serve only after its eval suite passes, so a model trained to answer
|
||||
wrongly on a trigger has at least been asked the suite.
|
||||
|
||||
## D. Facts worth knowing before starting
|
||||
|
||||
- Admin API: `127.0.0.1:9090`, no auth, `ctlClient` in `internal/cli/ctl.go`.
|
||||
- Admin API: `127.0.0.1:9090`, behind the token in
|
||||
`/var/lib/llmgw/admin-token` (A5), `ctlClient` in `internal/cli/ctl.go`.
|
||||
Metrics on `:9091` with `/prometheus/targets` beside them.
|
||||
- Store: `internal/store`, migrations under `internal/store/migrations/{sqlite,postgres}`;
|
||||
the PoC runs PostgreSQL under Patroni.
|
||||
|
||||
+508
-1
@@ -18,12 +18,519 @@ is only released once its **exit criterion** is met, not when its code lands.
|
||||
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | released |
|
||||
| v0.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.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) |
|
||||
| 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.15.1 — 2026-10-29
|
||||
|
||||
A 125B model on one card, a second mirror, and weights that run code
|
||||
refused. Strata runs as an engine: a mixture-of-experts model with its
|
||||
experts in RAM, served from one 24 GB card. A second model mirror keeps in
|
||||
step with the first, so an HA install has no single machine left. Pickled
|
||||
weights are refused unless a model says otherwise. A served model's context
|
||||
can be changed in place. The explorer says how fast a model would answer
|
||||
here before it is fetched, and a card in the wrong PCIe slot is said so.
|
||||
|
||||
**Upgrading.**
|
||||
- No migrations. Upgrade the control planes before the nodes, for the
|
||||
pickle refusal below.
|
||||
- A model whose weights are in a pickle format (`.bin`, `.pt`, `.pth`,
|
||||
`.ckpt`, `.pkl`) stops being placed: its node refuses the source. Give it
|
||||
its safetensors or GGUF, or add it again with `allow_pickle: true`
|
||||
(`llmgw ctl model add -f`) if its files were checked. Look at the
|
||||
models' sources before upgrading the nodes: one ending in
|
||||
`.ckpt` or `.bin`, or a mirror version holding `pytorch_model.bin`. Upgrade the control planes first: a node on
|
||||
this version refuses those weights until its control plane can say
|
||||
`allow_pickle`.
|
||||
- Strata is opt-in: nothing installs it until `engines` or an
|
||||
`engine_overrides` entry names a version (`0.2.7`).
|
||||
|
||||
### Added
|
||||
|
||||
- **Strata as an engine: a 125B model of experts on one card.** Strata
|
||||
(github.com/Niko1221/Strata) keeps a model's experts in RAM and the ones
|
||||
used most in VRAM. On an RTX 6000 24 GB with 251 GB of RAM it serves
|
||||
Qwen3.8-Flash-Next 125B (IQ2_XS) at 70–87 tokens a second to one client;
|
||||
more clients share about that, its slots buying fairness rather than
|
||||
throughput on one card. It runs a few families only — Qwen3.8-Flash-Next
|
||||
and its variants, Qwen3.6-35B-A3B, DeepSeek V4.1-Flash — each named in a
|
||||
profile's args with `--family` and `--size`
|
||||
(`--family qwen --size IQ2_XS --context 32768`). The source is the
|
||||
directory of the model's GGUF files, or the repository, which Strata then
|
||||
downloads itself, pinned. The agent installs it on an NVIDIA node of
|
||||
x86-64: its source pinned by digest, in a Python environment, and its
|
||||
release's engine checked against the digest GitHub publishes — the CUDA 13
|
||||
build for Turing and newer on driver 580, the CUDA 12.9 build for Pascal
|
||||
to Ada, a V100 included, on driver 575. A replica prepares its model the
|
||||
first time (minutes; its draft layer is a 5 GB download) and loads in
|
||||
about a minute and a half after. One card of one node per replica, for
|
||||
now; not in an offline bundle, its packages coming from PyPI.
|
||||
|
||||
- **A served model's context, changed.** `llmgw ctl model context MODEL
|
||||
TOKENS [--slots N]`, `POST /api/v1/models/{name}/context`, and Context
|
||||
length… in the console's model menu. The catalog, the engine's arguments and
|
||||
the memory change together, checked against what the model was trained for
|
||||
and what its cards hold; its replicas are then started again one by one. The
|
||||
models API shows `context_length` and `max_output`.
|
||||
|
||||
- **A second model mirror, kept in step.** In a three-machine install the setup screens put one on a database machine
|
||||
(`--second-mirror 1|2`). An install with one mirror gets one with `sudo llmgw mirror add-peer user@host`.
|
||||
- **In step:** each mirror reads the other every thirty seconds. It copies what it lacks, checksum checked, and
|
||||
removes what the other removed, even if it was down at the time (`llmgw mirror serve --peer`).
|
||||
- **Control planes:** they name both (`mirror.peers`) and use whichever answers.
|
||||
- **Nodes:** while one mirror is down, a node is given a model's file on the other.
|
||||
|
||||
This takes away the last single point of an HA install: with the first machine off, models are still added and
|
||||
fetched.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **An engine that computes in a process of its own group was not its
|
||||
replica's.** A GPU replica read as running on the CPU, and the memory it
|
||||
held on its card as somebody else's, which placement then kept free of
|
||||
other work. A process the replica started, at any remove, is now the
|
||||
replica's. And what a replica computes on, once sent, was never sent
|
||||
again: a wrong reading kept from before an agent's restart stayed wrong.
|
||||
It is sent again when it changes, and read again when a GPU replica was
|
||||
kept as the CPU.
|
||||
|
||||
- **VS Code's sign-in lost its place through the sign-in page**, so the
|
||||
prompt to confirm came every time; a link to the same gateway by plain
|
||||
http could take an https setting down to http; and a new build of the
|
||||
extension was not installed over an old one (the command now says
|
||||
`--force`).
|
||||
- **The install guide's recordings were not checked for secrets**, only
|
||||
the page; every frame is now.
|
||||
- **MCP server fields could not be emptied**, an address could carry a
|
||||
password, the cluster's own server could not be changed from the
|
||||
console, and a grant to a key that does not exist was said done.
|
||||
- **A guardrail service two policies named was asked as the first said**,
|
||||
not the stricter; a `null` answer read as safe; and with `restore` the
|
||||
request log kept the answer with the redacted values back in it.
|
||||
- **What an engine downloads by a Hugging Face id was not held to the
|
||||
pickle refusal** — an adapter by its id, a TensorRT-LLM model's; a model
|
||||
with either now needs `allow_pickle`.
|
||||
- **`server init` run again on a machine already set up was refused its
|
||||
memory**, as on an 8 GB install fixing its certificate with `--host`, and
|
||||
judges that would not be installed (no mirror) were counted.
|
||||
- **Removing an MCP server left its sealed credential in the store**, and
|
||||
replacing a credential under another header kept the old one sent. A
|
||||
server's secrets now go when it no longer reads them.
|
||||
- **A cloned repository could point the VS Code extension at another
|
||||
gateway.** `llmgw.gatewayUrl` is a machine setting now, never taken from a
|
||||
workspace.
|
||||
- **A node's memory bandwidth could be measured in its cache** on a busy
|
||||
node, and was 0 after a reconnect until the next heartbeat. The buffer is
|
||||
at least eight times the processor's last cache.
|
||||
- **A project kept in the cluster could have its questions embedded outside
|
||||
it.** The similar-question cache now asks the embedding model as the
|
||||
question's project would, external fallbacks refused, and skips a model
|
||||
that is itself outside. Its comparing no longer holds the cache's lock,
|
||||
which every project's lookups waited on.
|
||||
- **Restored values were left as placeholders in streamed tool calls**, in
|
||||
completions and in reasoning, and a value with a quote in a whole answer's
|
||||
tool-call arguments broke their JSON.
|
||||
- **An outside guardrail service read what `redact` had taken out**, and a
|
||||
streamed answer was sent to it twice. The docs now say a service stops a
|
||||
streamed answer only under `stream: buffer_all`.
|
||||
- **A replica found slow once could stay unused.** Its time to first token
|
||||
is forgotten after five minutes without an answer, so it is tried again.
|
||||
- **Weights that run code got past the refusal in four ways.** A mirror
|
||||
version already on a node was used without being looked at again; a
|
||||
`?query` or `#fragment` on an `hf:` source hid the file's name; `.pt2` was
|
||||
not on the list; and a profile could write `--load_format`,
|
||||
`--trust_remote_code` or an abbreviation such as `--trust-remote`, which
|
||||
vLLM and argparse read as the flag, or give SGLang `--load-format`. All
|
||||
refused now. TripoSR's preset says `allow_pickle`, without which it could
|
||||
not be added, and a shared ComfyUI environment takes its first member's.
|
||||
- **A temperature written as a string was refused.** Since the GenAI traces
|
||||
read it, `"temperature": "0.7"` got a 400 before reaching the engine; it
|
||||
is read loosely again, for the trace only.
|
||||
- **A reused answer could carry another caller's personal data.** With
|
||||
`restore: true` and the answer cache on, two callers' requests that
|
||||
redacted to the same text shared one answer — with the first caller's
|
||||
address put back in it. Such requests are now never answered from the
|
||||
cache nor kept in it.
|
||||
- **Granting a server's tools could take others away; revoking could close
|
||||
a level to every tool.** A level with no tool list sees what the levels
|
||||
above grant; giving it its first list made it see only that list. And a
|
||||
level left with an empty list was closed to every tool, which the screens
|
||||
did not show. Now a first list starts from the nearest list above, taking
|
||||
a server away leaves the rest or goes back to no list, the screens say
|
||||
when a level above keeps granted tools out of reach and which levels are
|
||||
closed, and the console reads and writes the level at once rather than
|
||||
from what it loaded.
|
||||
- **Similar questions about different pictures got each other's answer.**
|
||||
A question with a picture, a sound or a file in it is reused only when it
|
||||
is the same request.
|
||||
|
||||
### Added
|
||||
|
||||
- **A card in the wrong PCIe slot is said so.** Each agent reads how every
|
||||
card is linked — the fastest generation card and slot allow together, and
|
||||
the lanes in use — against the card's own most, from the kernel (the
|
||||
driver's figure is what card and slot allow together); a card on fewer lanes or
|
||||
an older generation is a node warning, with what it carries and what it
|
||||
could, on the dashboard, the console and in `llmgw doctor`.
|
||||
- **And on a web page, `/admin/mcp`.** For administrators: the same list,
|
||||
adding a server with its credential sealed, testing it and listing its
|
||||
tools, granting them in the tree beside that level's other grants and
|
||||
limits, revoking, changing, replacing the credential, turning it off,
|
||||
removing; and the line that points an agent at this gateway's `/mcp`.
|
||||
In English and Turkish; every change in the audit log, the secret not.
|
||||
- **The company's MCP servers in the console.** A screen of its own: each
|
||||
server with its state, who has its tools, its rate, the names of the
|
||||
credentials it is sent and its address; add one with a bearer token or
|
||||
a header kept sealed, test it and list its tools, grant them to a team,
|
||||
project, organization, person or key beside that level's other grants,
|
||||
revoke, change, replace the credential, turn off, remove.
|
||||
- **And on the cards, as this cluster would lay it out.** The explorer's
|
||||
**GPU tok/s** and a repository's files give one reply's speed for each
|
||||
layout the planner offers here — one card, tensor parallel across a
|
||||
node's cards, llama.cpp's layer split, a pipeline across nodes on a
|
||||
fabric — the fastest of them, with the layout in the tooltip. From each
|
||||
card's memory bandwidth by its model name, NVLink or PCIe between a
|
||||
node's cards, and InfiniBand or RoCE between nodes; a cluster of mixed
|
||||
classes is judged class by class. A model of experts reads 1.6 times its
|
||||
active share, as Qwen3-Coder-30B-A3B did on cuda01's RTX 6000 (144
|
||||
tok/s; qwen3-vl-8b Q8: 62).
|
||||
- **How fast a model would answer on a CPU, before it is fetched.** Each
|
||||
agent measures how fast its node's memory reads when it starts (`Host`
|
||||
gets `mem_bandwidth_gbs`, the node's view `mem_bandwidth_gbs`); the
|
||||
explorer's **CPU tok/s** column and a repository's GGUF files say about
|
||||
how many tokens a second one reply gets on the fastest node that holds
|
||||
the model — the bandwidth over the bytes a token reads, its active part
|
||||
for a model of experts. Calibrated on the PoC against llama.cpp's own
|
||||
timings: within 15% from 1.5B to 14B.
|
||||
- **The explorer lists what this cluster can run.** Judged by the nodes
|
||||
as they are now — every card's memory and compute capability, every
|
||||
node's RAM — through the same planner as serving a model, rather than a
|
||||
size picked from a list. A model too big for any node, in FP8 without a
|
||||
card of 8.9, in MLX, or in neither GGUF nor safetensors, is left out and
|
||||
counted; the page says what it judged by. With cards, safetensors repositories are
|
||||
listed beside GGUF. On a repository's page a file too big for the
|
||||
cluster is shown with what it needs and no Fetch button.
|
||||
- **Weights that run code are refused.** Python pickles — `.bin`, `.pt`,
|
||||
`.pth`, `.ckpt` — run whatever they hold when loaded, which is how the
|
||||
malicious models on Hugging Face work. The node refuses such a source,
|
||||
or a mirror version with one in it, before fetching it; adding such a
|
||||
model is refused; vLLM and SGLang downloading a model by its id read its
|
||||
safetensors only; the mirror page marks the file. `allow_pickle: true`
|
||||
on a model lets it through on purpose. The mirror's explorer no longer
|
||||
lists a repository with nothing a node loads (no GGUF, no safetensors)
|
||||
while "runs here" is ticked, and a repository's pickle files are not
|
||||
offered, only counted. BACKLOG M lists what is next:
|
||||
checksums against Hugging Face everywhere, its own scan, trusted
|
||||
publishers, engines without the internet.
|
||||
|
||||
## v0.15.0 — 2026-10-25
|
||||
|
||||
The company's tools and editors through the gateway, and guardrails that
|
||||
can lean on others. Its own MCP servers sit behind llmgw's `/mcp` under the
|
||||
guardrails, a rate and a breaker. VS Code gets the gateway's models from an
|
||||
extension the gateway serves. A guardrail policy can ask an outside
|
||||
service, and can put redacted values back in the answer. Chat goes to the
|
||||
replica answering soonest. A similar question can get the answer already
|
||||
given. A cached prompt can be priced apart. Traces follow the OpenTelemetry
|
||||
GenAI conventions, and Kong is written down from trying it. Also an install
|
||||
guide with every screen, at `/install`.
|
||||
|
||||
**Upgrading.**
|
||||
- Migrations 0049 (`mcp_servers`), 0050 (their rate and breaker) and 0051
|
||||
(`projects.cache_similar`), applied when the control plane starts.
|
||||
- `llmgw server init` now refuses a machine that would serve models without
|
||||
the memory for them: the starter model and the judges need 12 GB on one
|
||||
machine, where 8 GB was said before. `--no-model`, `--no-guard-model` or
|
||||
`--no-node` make it fit. A running cluster is not checked again.
|
||||
- Traces carry the GenAI conventions' `gen_ai.*` attributes beside the
|
||||
`llmgw.*` ones, which stay; docs/install.md, "Tracing", lists them.
|
||||
- Nothing else changes until it is turned on: `services` and `restore` in a
|
||||
guardrail policy, `keys cache --similar` with
|
||||
`router.cache.embedding_model`, `cached_per_1k` in a price.
|
||||
|
||||
### Added
|
||||
|
||||
- **Answers to similar questions.** `llmgw keys cache --similar 0.95` also
|
||||
gives a chat question the answer already given to one like it in the same
|
||||
conversation, compared by `router.cache.embedding_model`, as
|
||||
`x-llmgw-cache: similar` with the similarity. Held in memory beside the
|
||||
exact answers; nothing written to the store but the setting.
|
||||
- **A cached prompt costs the cached rate.** `cached_per_1k` in a price
|
||||
charges the prompt tokens an engine or provider read from its cache (the
|
||||
answer's `cached_tokens`) at their own rate; without it they cost what any
|
||||
prompt token does, as before.
|
||||
- **Chat goes to the replica answering soonest.** The router keeps each
|
||||
replica's time to first token, smoothed over its last answers, and the
|
||||
`chat` profile scores by it beside the queue and the KV cache: of two
|
||||
replicas of one model, the one on the slower card or the busier host gets
|
||||
less, and a replica not yet measured is tried.
|
||||
- **llmgw and Kong, written down** ([docs/kong.md](docs/kong.md)): Kong 3.9
|
||||
in front of llmgw by a plain route or its `ai-proxy`, each team's llmgw
|
||||
key passed on; Kong as a provider behind llmgw; and that Kong 3.9 counts
|
||||
no completion tokens of a streamed answer, so bill from llmgw's usage.
|
||||
- **A guardrail that is someone else's service.** A policy's `services`
|
||||
asks Azure Content Safety, Lakera, NeMo or a company's own classifier over
|
||||
HTTP: a body template, keys from files, dotted paths to where the answer
|
||||
says unsafe and why, on input, output or both, block or flag, and the
|
||||
policy's `fail` when it does not answer.
|
||||
- **Redacted values put back in the answer.** `restore: true` on a guardrail
|
||||
policy's input: the model reads `[EMAIL_1]`, the client reads its own
|
||||
address in the reply, streamed or not. Only the request's own values, held
|
||||
in memory for its life; the output rules read the answer as the client
|
||||
will.
|
||||
- **Traces in the OpenTelemetry GenAI conventions.** An inference request
|
||||
and each attempt at an engine carry `gen_ai.operation.name`, the requested
|
||||
and the answering model, `max_tokens`, `temperature` and `top_p` when set,
|
||||
the session as `gen_ai.conversation.id`, and the input, output and cached
|
||||
tokens, and an attempt the engine that answered as `gen_ai.provider.name`.
|
||||
Tools that read the conventions show llmgw's calls as model calls with no
|
||||
dashboard of their own. No prompt or answer text is ever in a span.
|
||||
- **llmgw for VS Code.** An extension of llmgw's own, served by every gateway
|
||||
at `/setup/vscode/llmgw.vsix` with that gateway's address in it, made from
|
||||
the binary itself: plain JavaScript, no dependencies, no build, and so no
|
||||
marketplace needed at a site with no internet. It offers the gateway's chat
|
||||
models to VS Code's language-model API — the chat's model picker, agent
|
||||
mode, and every extension that asks VS Code for a model — with the sizes
|
||||
and the tool and vision capabilities the catalog says, streamed, with tool
|
||||
calls and their results both ways. **VS Code** is a tool on the account
|
||||
page's **Connect your editor**: a key, the one-line install, and an **Open
|
||||
in VS Code** link that hands the key to the extension, which keeps it in
|
||||
VS Code's secret storage. **llmgw: Sign In** opens that page with a state
|
||||
of its own; a link without it is confirmed first, the gateway named.
|
||||
- **An install guide with every screen, at `/install`.** From three bare
|
||||
Ubuntu machines to a first answer: what each machine needs and how to
|
||||
check it, the release fetched and checked, the setup screens one by one,
|
||||
the system's state, and a first request with a key from the account page.
|
||||
The screens are a real three-machine install, recorded and kept as
|
||||
terminal frames — sharp at any size and a few kilobytes — with each
|
||||
session played back on request; the administrator's password and every
|
||||
key were masked before it was kept. `/about` says more of what llmgw does
|
||||
(documents, guardrails, sign-in, high availability, pictures and 3D), and
|
||||
its install steps are the ones of today, leading to the guide.
|
||||
- **The company's MCP tools under the guardrails, a rate and a breaker.**
|
||||
- **The answer:** the caller's policies read a tool's answer as the prompt it becomes. An instruction planted in a
|
||||
ticket is withheld as a pasted one would be, and personal data is redacted.
|
||||
- **The arguments:** they are read before they leave, so a secret pasted into one does not reach the company system.
|
||||
- **Rate:** `ctl mcp add --rate N` allows each caller N calls a minute to a server.
|
||||
- **Breaker:** five failures in a row take a server out for a minute, its tools off the list.
|
||||
|
||||
- **How fast the judge is, measured.** `llmgw ctl guard judge check` (and `POST /api/v1/guard/judges/check`) works
|
||||
out whether the judge keeps up with requests. It times three judgments of a 280-token prompt, then a burst at once,
|
||||
and sets both against the policies' wait. It says ok, tight or slow, about how many requests a second can be judged,
|
||||
and what to do: more cores, more judges, a GPU judge, a longer wait, or `fail: open`. `server init` runs it once the
|
||||
judge is up. If the machine is too slow it says so before the next steps, not after the first refused request.
|
||||
|
||||
- **The company's own MCP servers through llmgw's `/mcp`.** `llmgw ctl mcp add jira --url … --secret-file
|
||||
Authorization=FILE` registers one; `/mcp` then lists its tools as `jira.search` beside llmgw's own. Only keys granted
|
||||
them see them: `llmgw keys limits --tools 'jira.*'`, patterns at any level of the tenancy tree. None granted anywhere
|
||||
is none. The server's credentials stay on the control planes, kept in the store sealed or read by reference. The
|
||||
server is told the calling key, person and project. Each call is bounded in time and size, and its progress passes
|
||||
on to a streaming client. Every call is in the audit log with its arguments' size and digest, never their text. The
|
||||
admin API has `/api/v1/mcp/servers`.
|
||||
- **The cluster as a read-only MCP server.** Registered as `llmgw ctl mcp add llmgw --url llmgw:admin` and granted
|
||||
as `llmgw.*`, its tools say what `llmgw ctl` does:
|
||||
- `cluster_status`, `list_nodes`, `list_models`, `list_replicas`, `explain_placement`, `node_logs`;
|
||||
- nothing that changes the cluster.
|
||||
|
||||
It is answered in the control plane's own process, so there is no token to keep and every control plane serves it.
|
||||
The admin API serves the same at `POST /api/v1/mcp`. `ctl mcp add --bearer-file` keeps a token, sent as
|
||||
`Authorization: Bearer`.
|
||||
|
||||
- **Large models across cards and nodes.** For a model of experts on more than one card, the plan now adds vLLM's
|
||||
`--enable-expert-parallel`. It reads the expert count from `config.json` under any of its names, and sizes a model
|
||||
that also reads pictures by its `text_config`. Several nodes are offered in any number, not only powers of two. Each
|
||||
way now says what it needs of a card, part by part (weights, context, engine), and of all its cards together. This
|
||||
shows in `ctl model plan`, in the console's Serve a model, and in a serve job's first step. The serve form checks that
|
||||
a longer context still fits. `ctl model serve --env KEY=VALUE` puts engine variables such as `NCCL_DEBUG=INFO` on
|
||||
every node. docs/large-models.md has the rules, the commands, and a checklist for bringing a model up on a fabric.
|
||||
|
||||
- **Machines of the judges' own in a three-machine install.** The setup screen asks for up to two judge machines after
|
||||
the store's two; `server init` takes `--judge-node user@host` for each. Each is added as a node labelled
|
||||
`llmgw.role: judge`, and the judge's profile selects it, so the judges run there alone. Under load on a 4-core
|
||||
control plane that was also a node, 38 of 60 requests were refused for want of a judgment. With the judges on a node
|
||||
of their own, 2 were. A node with an `llmgw.role` label takes only profiles whose node selector asks for that role,
|
||||
so no other model is placed on the judges' machine.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A machine too small for the models it would serve is said so first.**
|
||||
The control plane that is also a node, serving the starter model and two
|
||||
judges, needs about 10.5 GB. On 8 GB the install finished every step and
|
||||
then had no room for the starter. Now the setup's checks fail its memory
|
||||
with what it would serve, and `server init` refuses before it installs
|
||||
anything, naming `--no-model`, `--no-guard-model` and `--no-node`.
|
||||
|
||||
- **Turkish pages are Turkish to the end.**
|
||||
- **Errors:** those from the store, catalog and mirror that a page shows ("not found", "not empty", "already in the
|
||||
catalog", the settings changed meanwhile, and others) are now in the page's language.
|
||||
- **Percent:** a percentage is written the language's way: `%93` in Turkish, `93%` in English.
|
||||
- **Footer:** the time at the foot of a page is in the language's date form.
|
||||
- **Stale forms:** they are refused with a sentence in that language rather than "bad request".
|
||||
|
||||
- **`llmgw keys limits` no longer clears the limits it was not given.** Setting one limit at a scope wrote the whole
|
||||
row, so `--tools 'jira.*'` alone took away that scope's rate, budget and model list. Only what is given changes now.
|
||||
`--replace` writes every limit, as the console's limits form does.
|
||||
|
||||
- **The plan refused tensor parallelism past a model's KV heads.** Qwen3-235B, with four KV heads, had no way to run
|
||||
on eight cards, though vLLM gives each card a copy of a head. A width that is a multiple of the KV heads is now
|
||||
offered, as is one that divides them.
|
||||
- **A big model on a slow mirror was failed while it downloaded.** The startup timeout counted the download, so
|
||||
hundreds of gigabytes at a few hundred MB/s ran past twenty minutes and the replica was started again from nothing.
|
||||
The timeout now runs from when a node's download last moved.
|
||||
|
||||
- **The judge init installs is no longer replaced by the leader's own.** Just after the control plane started, the
|
||||
leader saw a policy naming a judge that init was still fetching, and served one itself. Its entry replaced init's, so
|
||||
a second judge ran with the leader's sizing, and with judge machines it ran off them. The leader now waits ten minutes
|
||||
after it starts before it serves a judge itself. If someone adds one while it fetches, theirs stays.
|
||||
- **A judge the leader serves by itself is sized as init's is.** When a guard policy names a judge the catalog lacks,
|
||||
the leader serves it. That judge reserved 3 GB and no cores, and ran llama.cpp's prompt cache, which grew one to
|
||||
3.2 GB. Without cores of its own, a judgment on a busy node missed its 5 s budget, and `fail: closed` refused the
|
||||
request. It now gets what `server init` gives its judges: 2 GB, two cores each (fewer on a small node), and no
|
||||
prompt cache. Every judge served through `modelserve` runs with `--cache-ram 0`.
|
||||
|
||||
## v0.14.9 — 2026-10-19
|
||||
|
||||
Sign-in set up from the console, with Active Directory tried before it is
|
||||
kept. A node's console now shows what the machine is doing, and a node's web
|
||||
page shows what it runs. Also fixes found trying Active Directory against a
|
||||
Samba domain controller on the test cluster. Sign-in through it, nested
|
||||
groups and a disabled account suspended by the sync all worked there.
|
||||
|
||||
**Upgrading.**
|
||||
- No migration.
|
||||
- `auth.ldap` and `auth.oidc` are in the cluster's settings, as before. A
|
||||
block written into `server.yaml` on a cluster with a store was never
|
||||
read; the log said so. Put it in with `llmgw ctl config edit`, or set it up
|
||||
again from the console's Sign-in screen.
|
||||
- A Samba DC's own autogenerated certificate is refused (a negative serial
|
||||
and no SAN). Give it one from a CA.
|
||||
|
||||
### Added
|
||||
|
||||
- **Sign-in set up from the console.** Under People → Sign-in, `sudo llmgw` shows how people get in: single sign-on,
|
||||
Active Directory and SCIM, each on or off. It also shows the directory's last sync. **E** sets up Entra ID from the
|
||||
tenant and client IDs. **D** sets up Active Directory, and the directory is tried before it is kept: each server
|
||||
reached and bound, how many people may sign in, how many are in each group. Nothing is kept if a step fails. The
|
||||
password, client secret and SCIM token are kept in the store as `store:auth/…`, sealed by the master key. The
|
||||
domain's CA goes into the settings (`auth.ldap.ca`), so no file has to be put on each control plane. The admin API
|
||||
has the same: `GET /api/v1/auth`, `POST /api/v1/auth/ldap/test` and `PUT /api/v1/auth/secrets/{name}`.
|
||||
- **A node's page says what it runs.** It now shows the agent's version, marked when the control plane runs another,
|
||||
and the engines with their versions. Each GPU row now names the models loaded on that card, and each engine row its
|
||||
GPUs.
|
||||
- **`sudo llmgw` on a node opens on what the machine is doing.** A new Overview screen shows the agent's state and
|
||||
version, each control plane it dials and whether it answers, every GPU with the models on it, the engines running
|
||||
here, the free space for weights and engines, and the agent's warnings of the last hour. It is read on the node
|
||||
itself, every three seconds, so it needs no admin token and works while the control plane is out of reach.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **A broken `auth.ldap` no longer stops every control plane.** It is the cluster's setting. A CA file that one
|
||||
machine could not use made `llmgw server` exit on every control plane, and no `ctl config` could then put it right.
|
||||
Directory sign-in is now turned off on that control plane, with an error in its log. Local accounts still sign in.
|
||||
- **A second `llmgw server` on a machine stops before it competes to lead.** `llmgw server check` (no such command)
|
||||
ran a whole server. Its ports were taken, yet it went on to hold the leader's lease for three hours while the real
|
||||
control planes stood by. `server` now refuses words it does not know and takes its ports before anything else.
|
||||
- **The single sign-on and Active Directory guides said `server.yaml`.** On a cluster with a store, `auth` is in the
|
||||
cluster's settings and the file's block is not read. They now say `llmgw ctl config edit`, and what certificate a
|
||||
domain controller needs.
|
||||
|
||||
## v0.14.8 — 2026-10-18
|
||||
|
||||
Sign-in with an on-premises Active Directory, and Entra ID made safe for
|
||||
people in many groups. Also several keys for one outside provider, and a
|
||||
measure of what the gateway adds to a request.
|
||||
|
||||
**Upgrading.**
|
||||
- Migration 0048 adds the `user_directory` table, applied when the control
|
||||
plane starts.
|
||||
- Nothing changes until `auth.ldap` is set; docs/install.md, "Active
|
||||
Directory", has an example. It has been tested against a fake directory,
|
||||
not yet against a real domain controller.
|
||||
- A judge installed by an earlier `server init` is refitted by the leader
|
||||
by itself (below); the steps given for v0.14.7 are no longer needed.
|
||||
|
||||
### Added
|
||||
|
||||
- **Sign-in with Active Directory** (`auth.ldap`). People sign in on the gateway's form with their AD username or mail
|
||||
and password, checked against the domain controllers over LDAPS or StartTLS. Groups, nested ones included, give
|
||||
staff roles and places in the tree, as with OIDC. Accounts are tied to the `objectGUID` and made at first sign-in
|
||||
when `create_accounts` is on. Every 15 minutes the leader reads the directory. People disabled or deleted there are
|
||||
suspended and reinstated when they come back; roles and memberships follow their groups. A sync that would suspend
|
||||
more than half the accounts is refused. See docs/install.md, "Active Directory".
|
||||
|
||||
- **Several keys for one outside provider.** An external model takes `api_key_files`, each with an optional weight, in
|
||||
place of `api_key_file`. Each key is an endpoint of its own (`external:MODEL#1`, `#2`), so a key answering 429 is
|
||||
passed over while its breaker is open and the request goes to another. A key answering 401 is passed over too, and
|
||||
`external_key_refused` is sent, naming the endpoint, never the key. Usage records which key answered by that name.
|
||||
|
||||
- **`llmgw ctl bench overhead`**: what the gateway adds to a request, measured against an engine that answers at
|
||||
once. Gateway and engine run in one process on loopback, so no cluster is needed. The load is sent straight to the
|
||||
engine and through the gateway: bare, with authentication and accounting, and with the guardrails' pattern checks.
|
||||
It reports p50 and p99 latency, what the gateway added, and a rough CPU per thousand requests. On an Apple M5 the
|
||||
gateway added 0.05–0.42 ms at p50 and at most 0.49 ms at p99 (README).
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Entra ID's groups past 200 no longer take a role away.** For someone in more than 200 groups Entra sends none in
|
||||
the token, only a pointer to Graph. That was read as no groups, so an administrator lost their role at the next
|
||||
sign-in. Their role and memberships are now left as they were, and an error is logged saying what to change in the app
|
||||
registration. docs/install.md has an Entra ID section.
|
||||
- **An account made by SCIM or OIDC could not be reinstated.** Setting it active was refused as "never approved".
|
||||
- **An upgrade refits the judge an earlier `server init` installed.** Upgrading to v0.14.7 left such a judge as it was:
|
||||
no cores or one, 2 or 4 GB, the prompt cache on. On fewer than eight cores every request was refused until it was
|
||||
fixed by hand. Now the leader finds a judge whose entry is exactly what an earlier init wrote and gives it 2 GB, no
|
||||
prompt cache and cores by today's rule (up to four for one judge, two each for more). It then restarts the judge's
|
||||
replicas onto that. The number of replicas is left as it is. A judge whose entry was changed by hand is left alone,
|
||||
and the log says so once.
|
||||
- **A restart is no longer refused for room another restart is giving back.** With every core of a node reserved, two
|
||||
models restarted together, one to need more and one less (as after `ctl model resources` on a judge and a starter),
|
||||
had the first refused: "fits on no node even with this replica stopped", though the second was about to give back
|
||||
its cores. A restart now waits for other restarts under way, up to the drain grace, before it is refused. A drained
|
||||
replica also counts a replica being restarted that still answers as serving, so replicas restarted one after
|
||||
another no longer wait out the five-minute grace each.
|
||||
|
||||
## v0.14.7 — 2026-10-16
|
||||
|
||||
The judge installed by `server init` sized for what it does. On a machine
|
||||
of fewer than eight cores v0.14.6 left every judgment past its budget, so
|
||||
`fail: closed` refused every request. A single-machine install of v0.14.7
|
||||
on a 4-core VM passed 20 of 20, and on 8 cores 20 of 20 under load.
|
||||
|
||||
**Upgrading.**
|
||||
- No migration.
|
||||
- **Upgrading does not change a judge already in the catalog.** v0.14.6
|
||||
and earlier installed two judges, reserving no cores or one, and 2 or
|
||||
4 GB. To give them what they need:
|
||||
`llmgw ctl model resources qwen3guard-0.6b --memory 2 --cores 2`, then
|
||||
`llmgw ctl restart --model qwen3guard-0.6b`. The two then need four cores
|
||||
on a single machine; below that, a longer `latency_budget` or
|
||||
`fail: open`.
|
||||
- On a node whose cores are all reserved, `ctl restart --model` cannot
|
||||
start the new replica beside the old one and waits. Stop the old engine
|
||||
process, and the replica comes back with the new figures.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Both judges fit an 8 GB machine again.** v0.14.6 had `server init` reserve 4 GB a judge replica. That figure came
|
||||
from one replica at 3.2 GB after a day of traffic. The growth was llama.cpp's prompt cache, up to 8 GB by default,
|
||||
which a judge never reuses: each text it reads is different. On a 4-core, 8 GB machine the second judge then fitted
|
||||
nowhere ("has 7 GB of memory with 1 free, needs 4"). The judge now runs with `--cache-ram 0`, uses about 1.6 GB and
|
||||
stays there, and reserves 2.
|
||||
- **One machine runs one judge, on up to four cores.** A second replica on the same machine shares its cores and is
|
||||
lost with it, so it bought nothing but half the speed of each. `server init` on a single machine now serves one judge
|
||||
on up to four cores (2.3 s a judgment where two on two cores took 4.2). Where there will be other nodes, it serves two
|
||||
on two cores each, as in HA setups. `--judge-replicas` and the setup's Copies still choose.
|
||||
- **A judge gets the two cores it needs.** v0.14.6 gave each judge replica one core on machines under eight. A judgment
|
||||
of about 280 tokens takes 8.0 s on one core, 4.2 s on two and 2.3 s on four, against the 5 s budget, so on a 4-core
|
||||
machine every judgment timed out and every request was refused. Where there are two judges, each now reserves two
|
||||
cores (one only below four CPUs), and the one judge of a single machine up to four; the starter has what is left, or
|
||||
shares when nothing is. A judge on two cores stays close to its budget: give it more cores or a GPU, or raise
|
||||
`latency_budget`, where requests must not wait.
|
||||
|
||||
## v0.14.6 — 2026-10-16
|
||||
|
||||
Setup that says what each part of an install is and lets every one be
|
||||
|
||||
@@ -40,7 +40,8 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
||||
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | released |
|
||||
| v0.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.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) |
|
||||
| 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
|
||||
@@ -183,6 +184,8 @@ Every page is also in the binary: `llmgw docs`, or **Manual** in the console.
|
||||
cluster that exists
|
||||
- [docs/kubernetes.md](docs/kubernetes.md) — the control plane on
|
||||
Kubernetes with the Helm chart, and why GPU nodes stay outside it
|
||||
- [docs/kong.md](docs/kong.md) — llmgw behind Kong Gateway, or Kong behind
|
||||
llmgw: the configuration, what Kong counts, and which to choose
|
||||
- [docs/fabrics.md](docs/fabrics.md) — InfiniBand fabrics and NVLink: what
|
||||
llmgw needs of them, checking a node with `llmgw doctor`, and what to look at
|
||||
when a replica spanning nodes will not run
|
||||
@@ -257,6 +260,33 @@ Add `--json` to any command for machine-readable output.
|
||||
says nothing about which of six constraints bit, so it prints every candidate
|
||||
considered and the reason it was rejected.
|
||||
|
||||
## What llmgw adds to a request
|
||||
|
||||
Measured with `llmgw ctl bench overhead`. It starts an engine that answers
|
||||
at once and a gateway in front of it, in one process on loopback. It sends
|
||||
the same load straight to the engine and through the gateway, and reports
|
||||
what the gateway added. Run on an Apple M5 (10 CPUs, load about 2) on
|
||||
2026-10-16, 10 s a step, 72,000 requests with none failed:
|
||||
|
||||
| Through the gateway | added at p50 | added at p99 |
|
||||
|---|---|---|
|
||||
| bare | 0.06–0.24 ms | 0.10–0.33 ms |
|
||||
| with authentication and usage accounting | 0.05–0.32 ms | 0.09–0.37 ms |
|
||||
| with the guardrails' pattern checks as well | 0.10–0.42 ms | 0.15–0.49 ms |
|
||||
|
||||
The range is across 100 to 2,000 requests a second. The overhead was
|
||||
*lowest* at 2,000, most likely because a busier processor keeps its cores
|
||||
awake. The guardrails' judge model is not in these figures: a judgment
|
||||
takes seconds on CPUs (see [install.md](docs/install.md)) and is a model's
|
||||
time, not the gateway's.
|
||||
|
||||
The command also prints the gateway's CPU per thousand requests. Gateway,
|
||||
engine and load share one process there, so that figure is rough (0.04 to
|
||||
0.65 s per thousand in the run above); the latency is the one to quote.
|
||||
Figures from a VM on a busy host move with the host: run it where you will
|
||||
deploy, and keep the load it prints beside the result. CI runs a one-second
|
||||
version on every change (`TestTheGatewaysOverheadIsMeasured`).
|
||||
|
||||
## Running the router
|
||||
|
||||
```sh
|
||||
@@ -284,8 +314,9 @@ within 1.24x of idle while 500 concurrent batch requests run, against 58x with
|
||||
admission off.
|
||||
|
||||
Routing profiles select how endpoints are scored: `chat` favours session
|
||||
affinity, `batch` favours the shortest queue, `long-context` favours free KV
|
||||
cache. Pick one per model in the config, or per request with the
|
||||
affinity and the replica that has been answering soonest (its time to first
|
||||
token, smoothed), `batch` favours the shortest queue, `long-context` favours
|
||||
free KV cache. Pick one per model in the config, or per request with the
|
||||
`x-llmgw-profile` header.
|
||||
|
||||
## sim-engine
|
||||
|
||||
@@ -1206,9 +1206,12 @@ type Host struct {
|
||||
CpuFlags []string `protobuf:"bytes,5,rep,name=cpu_flags,json=cpuFlags,proto3" json:"cpu_flags,omitempty"`
|
||||
// nvidia_driver is the driver version, empty when there is none. A card
|
||||
// without a driver is not a GPU anything can use.
|
||||
NvidiaDriver string `protobuf:"bytes,6,opt,name=nvidia_driver,json=nvidiaDriver,proto3" json:"nvidia_driver,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
NvidiaDriver string `protobuf:"bytes,6,opt,name=nvidia_driver,json=nvidiaDriver,proto3" json:"nvidia_driver,omitempty"`
|
||||
// mem_bandwidth_gbs is how fast the memory reads, in GB/s, measured when
|
||||
// the agent started: what decides a model's speed on the CPU. 0 unknown.
|
||||
MemBandwidthGbs float64 `protobuf:"fixed64,7,opt,name=mem_bandwidth_gbs,json=memBandwidthGbs,proto3" json:"mem_bandwidth_gbs,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Host) Reset() {
|
||||
@@ -1283,6 +1286,13 @@ func (x *Host) GetNvidiaDriver() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *Host) GetMemBandwidthGbs() float64 {
|
||||
if x != nil {
|
||||
return x.MemBandwidthGbs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Storage is one filesystem a node keeps weights on.
|
||||
//
|
||||
// Per filesystem rather than per configured directory: free space belongs to
|
||||
@@ -1579,7 +1589,14 @@ type GPU struct {
|
||||
// is then 1000 + 100 × card + device, uuid the instance's (MIG-…), and
|
||||
// memory the instance's share; utilization_percent stays zero, because no
|
||||
// driver measures it per instance.
|
||||
Mig *MIGDevice `protobuf:"bytes,22,opt,name=mig,proto3" json:"mig,omitempty"`
|
||||
Mig *MIGDevice `protobuf:"bytes,22,opt,name=mig,proto3" json:"mig,omitempty"`
|
||||
// How the card is linked to its host: the fastest generation card and
|
||||
// slot allow together, the card's own fastest, and the lanes in use and
|
||||
// the card's most. Zero when the driver did not say.
|
||||
PcieGen int32 `protobuf:"varint,23,opt,name=pcie_gen,json=pcieGen,proto3" json:"pcie_gen,omitempty"`
|
||||
PcieGpuGen int32 `protobuf:"varint,24,opt,name=pcie_gpu_gen,json=pcieGpuGen,proto3" json:"pcie_gpu_gen,omitempty"`
|
||||
PcieWidth int32 `protobuf:"varint,25,opt,name=pcie_width,json=pcieWidth,proto3" json:"pcie_width,omitempty"`
|
||||
PcieWidthMax int32 `protobuf:"varint,26,opt,name=pcie_width_max,json=pcieWidthMax,proto3" json:"pcie_width_max,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -1768,6 +1785,34 @@ func (x *GPU) GetMig() *MIGDevice {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *GPU) GetPcieGen() int32 {
|
||||
if x != nil {
|
||||
return x.PcieGen
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GPU) GetPcieGpuGen() int32 {
|
||||
if x != nil {
|
||||
return x.PcieGpuGen
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GPU) GetPcieWidth() int32 {
|
||||
if x != nil {
|
||||
return x.PcieWidth
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (x *GPU) GetPcieWidthMax() int32 {
|
||||
if x != nil {
|
||||
return x.PcieWidthMax
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// MIGDevice is which card a MIG instance is carved from, and how.
|
||||
type MIGDevice struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -2943,8 +2988,12 @@ type Heartbeat struct {
|
||||
// is not handed out. Empty from an agent that does not measure it, or from
|
||||
// one that could not this time.
|
||||
GpuForeignMemoryMb []int32 `protobuf:"varint,19,rep,packed,name=gpu_foreign_memory_mb,json=gpuForeignMemoryMb,proto3" json:"gpu_foreign_memory_mb,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
// mem_bandwidth_gbs is how fast the node's memory reads, in GB/s, once
|
||||
// the agent has measured it — in the background after it starts, so a
|
||||
// registration usually has none yet. 0 means not measured.
|
||||
MemBandwidthGbs float64 `protobuf:"fixed64,20,opt,name=mem_bandwidth_gbs,json=memBandwidthGbs,proto3" json:"mem_bandwidth_gbs,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *Heartbeat) Reset() {
|
||||
@@ -3110,6 +3159,13 @@ func (x *Heartbeat) GetGpuForeignMemoryMb() []int32 {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *Heartbeat) GetMemBandwidthGbs() float64 {
|
||||
if x != nil {
|
||||
return x.MemBandwidthGbs
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// Provision is the software a node should have: which engines, at which
|
||||
// versions. Nothing more.
|
||||
//
|
||||
@@ -3790,7 +3846,10 @@ type EngineProfile struct {
|
||||
Adapters []*LoraAdapter `protobuf:"bytes,12,rep,name=adapters,proto3" json:"adapters,omitempty"`
|
||||
// kv_events has a vLLM engine publish its KV cache events to the agent, for
|
||||
// the router's precise index.
|
||||
KvEvents bool `protobuf:"varint,13,opt,name=kv_events,json=kvEvents,proto3" json:"kv_events,omitempty"`
|
||||
KvEvents bool `protobuf:"varint,13,opt,name=kv_events,json=kvEvents,proto3" json:"kv_events,omitempty"`
|
||||
// allow_pickle lets the node fetch weights in a pickle format, which run
|
||||
// code when loaded: the catalog's allow_pickle, set on purpose.
|
||||
AllowPickle bool `protobuf:"varint,14,opt,name=allow_pickle,json=allowPickle,proto3" json:"allow_pickle,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -3916,6 +3975,13 @@ func (x *EngineProfile) GetKvEvents() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (x *EngineProfile) GetAllowPickle() bool {
|
||||
if x != nil {
|
||||
return x.AllowPickle
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RemoveWeights deletes cached model files a node no longer needs.
|
||||
//
|
||||
// The paths are computed by the control plane from the catalog, but the agent
|
||||
@@ -5180,7 +5246,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\x1aA\n" +
|
||||
"\x13EngineVersionsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xb1\x01\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\xdd\x01\n" +
|
||||
"\x04Host\x12\x13\n" +
|
||||
"\x05os_id\x18\x01 \x01(\tR\x04osId\x12\x1d\n" +
|
||||
"\n" +
|
||||
@@ -5189,7 +5255,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"osCodename\x12\x12\n" +
|
||||
"\x04arch\x18\x04 \x01(\tR\x04arch\x12\x1b\n" +
|
||||
"\tcpu_flags\x18\x05 \x03(\tR\bcpuFlags\x12#\n" +
|
||||
"\rnvidia_driver\x18\x06 \x01(\tR\fnvidiaDriver\"u\n" +
|
||||
"\rnvidia_driver\x18\x06 \x01(\tR\fnvidiaDriver\x12*\n" +
|
||||
"\x11mem_bandwidth_gbs\x18\a \x01(\x01R\x0fmemBandwidthGbs\"u\n" +
|
||||
"\aStorage\x12\x12\n" +
|
||||
"\x04path\x18\x01 \x01(\tR\x04path\x12\x1d\n" +
|
||||
"\n" +
|
||||
@@ -5219,7 +5286,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"leaderPort\x12\x1d\n" +
|
||||
"\n" +
|
||||
"profile_fp\x18\x0f \x01(\tR\tprofileFp\x12+\n" +
|
||||
"\acompute\x18\x10 \x01(\v2\x11.llmgw.v1.ComputeR\acompute\"\xca\x06\n" +
|
||||
"\acompute\x18\x10 \x01(\v2\x11.llmgw.v1.ComputeR\acompute\"\xcc\a\n" +
|
||||
"\x03GPU\x12\x14\n" +
|
||||
"\x05index\x18\x01 \x01(\x05R\x05index\x12\x12\n" +
|
||||
"\x04uuid\x18\x02 \x01(\tR\x04uuid\x12\x12\n" +
|
||||
@@ -5245,7 +5312,13 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"\fcluster_uuid\x18\x13 \x01(\tR\vclusterUuid\x12\x1b\n" +
|
||||
"\tclique_id\x18\x14 \x01(\tR\bcliqueId\x12)\n" +
|
||||
"\x10fabric_bandwidth\x18\x15 \x01(\tR\x0ffabricBandwidth\x12%\n" +
|
||||
"\x03mig\x18\x16 \x01(\v2\x13.llmgw.v1.MIGDeviceR\x03mig\x1a;\n" +
|
||||
"\x03mig\x18\x16 \x01(\v2\x13.llmgw.v1.MIGDeviceR\x03mig\x12\x19\n" +
|
||||
"\bpcie_gen\x18\x17 \x01(\x05R\apcieGen\x12 \n" +
|
||||
"\fpcie_gpu_gen\x18\x18 \x01(\x05R\n" +
|
||||
"pcieGpuGen\x12\x1d\n" +
|
||||
"\n" +
|
||||
"pcie_width\x18\x19 \x01(\x05R\tpcieWidth\x12$\n" +
|
||||
"\x0epcie_width_max\x18\x1a \x01(\x05R\fpcieWidthMax\x1a;\n" +
|
||||
"\rNicPathsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"\x84\x02\n" +
|
||||
@@ -5361,7 +5434,7 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"\x06status\x18\x03 \x01(\tR\x06status\x12!\n" +
|
||||
"\fcluster_uuid\x18\x04 \x01(\tR\vclusterUuid\x12\x1b\n" +
|
||||
"\tclique_id\x18\x05 \x01(\tR\bcliqueId\x12\x1c\n" +
|
||||
"\tbandwidth\x18\x06 \x01(\tR\tbandwidth\"\xa9\a\n" +
|
||||
"\tbandwidth\x18\x06 \x01(\tR\tbandwidth\"\xd5\a\n" +
|
||||
"\tHeartbeat\x12\x17\n" +
|
||||
"\anode_id\x18\x01 \x01(\tR\x06nodeId\x12+\n" +
|
||||
"\x12gpu_memory_used_mb\x18\x02 \x03(\x05R\x0fgpuMemoryUsedMb\x12!\n" +
|
||||
@@ -5383,7 +5456,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
"gpu_fabric\x18\x10 \x03(\v2\x18.llmgw.v1.GPUFabricStateR\tgpuFabric\x12\"\n" +
|
||||
"\x04imex\x18\x11 \x01(\v2\x0e.llmgw.v1.IMEXR\x04imex\x12\x1b\n" +
|
||||
"\tcpu_count\x18\x12 \x01(\x05R\bcpuCount\x121\n" +
|
||||
"\x15gpu_foreign_memory_mb\x18\x13 \x03(\x05R\x12gpuForeignMemoryMb\x1aA\n" +
|
||||
"\x15gpu_foreign_memory_mb\x18\x13 \x03(\x05R\x12gpuForeignMemoryMb\x12*\n" +
|
||||
"\x11mem_bandwidth_gbs\x18\x14 \x01(\x01R\x0fmemBandwidthGbs\x1aA\n" +
|
||||
"\x13EngineVersionsEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\";\n" +
|
||||
@@ -5462,7 +5536,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\"\xcd\x03\n" +
|
||||
"cpuThreads\"\xf0\x03\n" +
|
||||
"\rEngineProfile\x12\x12\n" +
|
||||
"\x04name\x18\x01 \x01(\tR\x04name\x12\x16\n" +
|
||||
"\x06engine\x18\x02 \x01(\tR\x06engine\x12\x1b\n" +
|
||||
@@ -5478,7 +5552,8 @@ const file_llmgw_v1_agent_proto_rawDesc = "" +
|
||||
" \x01(\tR\tmodelPath\x12*\n" +
|
||||
"\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\x1a6\n" +
|
||||
"\tkv_events\x18\r \x01(\bR\bkvEvents\x12!\n" +
|
||||
"\fallow_pickle\x18\x0e \x01(\bR\vallowPickle\x1a6\n" +
|
||||
"\bEnvEntry\x12\x10\n" +
|
||||
"\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" +
|
||||
"\x05value\x18\x02 \x01(\tR\x05value:\x028\x01\"%\n" +
|
||||
|
||||
@@ -238,6 +238,9 @@ message Host {
|
||||
// nvidia_driver is the driver version, empty when there is none. A card
|
||||
// without a driver is not a GPU anything can use.
|
||||
string nvidia_driver = 6;
|
||||
// mem_bandwidth_gbs is how fast the memory reads, in GB/s, measured when
|
||||
// the agent started: what decides a model's speed on the CPU. 0 unknown.
|
||||
double mem_bandwidth_gbs = 7;
|
||||
}
|
||||
|
||||
// Storage is one filesystem a node keeps weights on.
|
||||
@@ -334,6 +337,13 @@ message GPU {
|
||||
// memory the instance's share; utilization_percent stays zero, because no
|
||||
// driver measures it per instance.
|
||||
MIGDevice mig = 22;
|
||||
// How the card is linked to its host: the fastest generation card and
|
||||
// slot allow together, the card's own fastest, and the lanes in use and
|
||||
// the card's most. Zero when the driver did not say.
|
||||
int32 pcie_gen = 23;
|
||||
int32 pcie_gpu_gen = 24;
|
||||
int32 pcie_width = 25;
|
||||
int32 pcie_width_max = 26;
|
||||
}
|
||||
|
||||
// MIGDevice is which card a MIG instance is carved from, and how.
|
||||
@@ -588,6 +598,11 @@ message Heartbeat {
|
||||
// is not handed out. Empty from an agent that does not measure it, or from
|
||||
// one that could not this time.
|
||||
repeated int32 gpu_foreign_memory_mb = 19;
|
||||
|
||||
// mem_bandwidth_gbs is how fast the node's memory reads, in GB/s, once
|
||||
// the agent has measured it — in the background after it starts, so a
|
||||
// registration usually has none yet. 0 means not measured.
|
||||
double mem_bandwidth_gbs = 20;
|
||||
}
|
||||
|
||||
// Provision is the software a node should have: which engines, at which
|
||||
@@ -749,6 +764,9 @@ message EngineProfile {
|
||||
// kv_events has a vLLM engine publish its KV cache events to the agent, for
|
||||
// the router's precise index.
|
||||
bool kv_events = 13;
|
||||
// allow_pickle lets the node fetch weights in a pickle format, which run
|
||||
// code when loaded: the catalog's allow_pickle, set on purpose.
|
||||
bool allow_pickle = 14;
|
||||
}
|
||||
|
||||
// RemoveWeights deletes cached model files a node no longer needs.
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -579,6 +579,15 @@ claude mcp add --transport http llmgw https://llm.example.com/mcp \
|
||||
--header "Authorization: Bearer $LLMGW_API_KEY"
|
||||
```
|
||||
|
||||
**Your company's tools.** Where an administrator has registered the
|
||||
company's own MCP servers (Jira, a file server, a database) and your key is
|
||||
granted their tools, the same `/mcp` lists them too, each as `server.tool`
|
||||
(`jira.search`). Give your agent only llmgw's `/mcp` and your key: the
|
||||
servers' own credentials stay with llmgw. A tool your key is not granted is
|
||||
not listed, and calling it by name is refused. Each call is in the audit
|
||||
log under your key: which tool, the size of what was sent, how it ended and
|
||||
how long it took, never what it said.
|
||||
|
||||
### Licences you leave out
|
||||
|
||||
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
|
||||
|
||||
+56
-4
@@ -2,8 +2,8 @@
|
||||
|
||||
`sudo llmgw`, with nothing after it, on a machine llmgw runs on. On a machine
|
||||
with nothing installed it opens the setup screens; on a control plane, the
|
||||
console described here; on a node, the node's own console, which edits
|
||||
`agent.yaml`. Everything on these screens can also be done by command — each
|
||||
console described here; on a node, the node's own console, which shows
|
||||
what runs on that machine and edits `agent.yaml`. Everything on these screens can also be done by command — each
|
||||
screen says which — so a script never needs them and a person never needs
|
||||
to remember the commands.
|
||||
|
||||
@@ -173,6 +173,23 @@ search…** as an administrator, or **Delete…** it. People
|
||||
fill collections from their own machines with `llmgw-kb`
|
||||
([knowledge.md](knowledge.md)). Off on a SQLite store, and says why.
|
||||
|
||||
**MCP servers** lists the company's own MCP servers that agents reach through
|
||||
the gateway's `/mcp`, their tools named `server.tool`: whether each is on,
|
||||
who has been granted its tools, its rate per caller, the names of the
|
||||
credentials it is sent (never their values) and its address. **A** adds one:
|
||||
its name, which is its tools' prefix, its address, and a bearer token or
|
||||
another header, kept sealed in the store. On a row: **Test, and list its
|
||||
tools**, with its credential, as a call would; **Grant its tools to…** a
|
||||
team, project, organization, person or key — all of them, `jira.*`, or some
|
||||
by name — beside whatever else that level is granted, and saying when a
|
||||
level above keeps them out of reach; **Revoke** a grant;
|
||||
**Change…** its address, timeout or rate; **Replace its credential…**;
|
||||
**Turn off** (its tools are listed nowhere and every call refused) or on;
|
||||
**Remove…**. A server's tools are seen nowhere until granted. The screen
|
||||
also says how a person points an agent at the gateway, with a key of their
|
||||
own from `/profile/keys`. `llmgw ctl mcp` does the same from a shell, and
|
||||
the web page **MCP servers** (`/admin/mcp`, administrators only) in a browser.
|
||||
|
||||
**Mirror** is what the model mirror holds, one row for each version of each
|
||||
model — a file, or the set of files a model of several is: shards, a vision
|
||||
projector, a config and tokenizer — with how far its download has got and
|
||||
@@ -246,6 +263,28 @@ long a key made there may live, and is the expiry of one made without any
|
||||
Limits are requests and tokens a minute, a budget per day or month, and the
|
||||
models allowed. Each level only narrows what is above it.
|
||||
|
||||
**Sign-in** — how people get in besides a password kept here: single
|
||||
sign-on, Active Directory and provisioning, each on or off, with the
|
||||
directory's last sync (how many people, accounts kept in step, suspended,
|
||||
reinstated).
|
||||
- **E** sets up Entra ID. Give the tenant ID, the client ID and secret,
|
||||
and the groups for administrators and operators. A provider other than
|
||||
Entra takes its issuer URL in place of the tenant. The form shows the
|
||||
redirect address to register.
|
||||
- **D** sets up Active Directory: the servers, where people are, the
|
||||
service account and its password, the domain's CA certificate as a PEM
|
||||
file on this machine, the group whose members may sign in, and groups
|
||||
for roles and teams.
|
||||
- Before it is kept, the directory is tried from the control plane: each
|
||||
server reached and bound, how many people may sign in, how many are in
|
||||
each group. If any step fails, nothing is kept and the steps are shown.
|
||||
**T** tries the settings in force.
|
||||
- On the provisioning row, Enter makes a SCIM token, shown once.
|
||||
- The password, the client secret and the token are kept in the store,
|
||||
sealed by the master key when one is set. The settings name them as
|
||||
`store:auth/…`, so each control plane has them with no file put on it.
|
||||
The CA is written into the settings themselves.
|
||||
|
||||
**Guardrails…** on a row lists the guardrail policies server.yaml defines.
|
||||
Each policy is marked in one of three ways:
|
||||
|
||||
@@ -335,5 +374,18 @@ to restart it.
|
||||
|
||||
## The node's console
|
||||
|
||||
On a node, `sudo llmgw` opens `agent.yaml` in the same settings view, and the
|
||||
manual. The cluster is watched and changed from a control plane.
|
||||
On a node, `sudo llmgw` opens on **Overview**: what this machine is doing, read
|
||||
on the machine itself, so it works without an admin token and while the control
|
||||
plane is out of reach. Refreshed every three seconds, it shows
|
||||
|
||||
- llmgw-agent's state and since when, and the version of llmgw;
|
||||
- each control plane the agent dials, from `agent.yaml` and the list the
|
||||
leader last sent, and whether it can be reached;
|
||||
- each GPU: how busy, its memory, its heat, and the models on it;
|
||||
- the engines running here, from the agent's own records: replica, model,
|
||||
engine, GPUs, port, whether the process is still there, and for how long;
|
||||
- the free space where weights and engines are kept;
|
||||
- the agent's warnings and errors of the last hour.
|
||||
|
||||
Below it, `agent.yaml` in the same settings view, and the manual. The cluster
|
||||
is watched and changed from a control plane.
|
||||
+3
-1
@@ -72,7 +72,9 @@ each answer that may be reused, as the client got it, and a hash of the
|
||||
request it answers, in the gateway process's memory — never in the store,
|
||||
never on disk — for the project's TTL or until pushed out by newer ones
|
||||
(`router.cache.max_bytes`), and gone with a restart. Never under a `no_store`
|
||||
policy. Forgetting a person does not reach into it; the answers age out by
|
||||
policy. A project that reuses answers to similar questions (`--similar`)
|
||||
also has each question's last user message embedded, and the vector kept
|
||||
beside its answer, in the same memory and for as long. Forgetting a person does not reach into it; the answers age out by
|
||||
the TTL. The store keeps only the setting, and a usage record's mark that it
|
||||
was answered from the cache.
|
||||
|
||||
|
||||
@@ -91,6 +91,21 @@ guard:
|
||||
value is the same placeholder throughout a request).
|
||||
- **`block`** stops it.
|
||||
|
||||
**Putting them back.** With `restore: true` in a policy's `input`, the
|
||||
values a request had redacted are put back in its answer: the model reads
|
||||
"write to [PERSON_1] at [EMAIL_1]", the client reads the name and address
|
||||
it sent. Streamed answers too — a placeholder split across two events is
|
||||
held until it is whole. Only that request's own values, only into its
|
||||
answer; the map is kept in memory for the request and nowhere else. The
|
||||
output rules then read the answer as the client will, so a policy that
|
||||
redacts personal data in answers still does.
|
||||
|
||||
```yaml
|
||||
input:
|
||||
pii: {email: redact, phone: redact}
|
||||
restore: true
|
||||
```
|
||||
|
||||
**Shapes.**
|
||||
|
||||
- `system_prompt` is put first.
|
||||
@@ -258,6 +273,47 @@ themselves.
|
||||
context rather than overflowing it and going unjudged.
|
||||
- **Cache:** the same text is not asked about twice within five minutes.
|
||||
|
||||
## An outside service
|
||||
|
||||
A site required to use a particular guardrail — Azure Content Safety,
|
||||
Lakera Guard, NeMo Guardrails, a classifier of its own — names it in a
|
||||
policy's `services`. It is asked over HTTP beside the patterns and before
|
||||
the judge; its "unsafe" blocks or flags like theirs, and a service that does
|
||||
not answer is what the policy's `fail` says.
|
||||
|
||||
```yaml
|
||||
policies:
|
||||
strict:
|
||||
fail: closed
|
||||
services:
|
||||
- name: lakera
|
||||
url: https://api.lakera.ai/v2/guard
|
||||
header_files: {Authorization: /etc/llmgw/secrets/lakera} # "Bearer …", read each call
|
||||
body: '{"messages": [{"role": "user", "content": {{json .Text}}}]}'
|
||||
blocked: flagged # where the answer says unsafe
|
||||
reason: breakdown.0.detector_type
|
||||
on: both # input, output or both
|
||||
action: block # or flag
|
||||
timeout: 2s
|
||||
```
|
||||
|
||||
`body` is a template given `.Text` (`{{json .Text}}` writes it as a JSON
|
||||
string; empty sends `{"input": …}`). `blocked` and `reason` are dotted paths
|
||||
into the JSON answer, numbers for list items; true, a number above zero, a
|
||||
non-empty list or a string other than "false" is unsafe. On input the
|
||||
service reads the latest user turn as the model will, with what `redact`
|
||||
took out still out; on output the whole answer, once, at its end, as the
|
||||
client reads it — with `restore`, the values put back. Being at the end, a
|
||||
service can stop a streamed answer only where it is held back
|
||||
(`stream: buffer_all`): released a sentence at a time, the client has read
|
||||
it by then, and what the service finds ends the stream with
|
||||
`content_filter`, flags it and is recorded. The rule recorded is
|
||||
`service:<name>:<reason>`. The service is told the text only, never who
|
||||
sent it. Under `fail: open`, the default, a service that cannot be asked
|
||||
lets the text through — and a caller can make it fail, sending what it
|
||||
refuses to read; give `fail: closed` where the service must decide. Of an
|
||||
answer asked for with `n` above 1, the service reads the first.
|
||||
|
||||
## Where a policy applies
|
||||
|
||||
`guard.default` applies everywhere. Others are attached to a part of the
|
||||
|
||||
+389
-20
@@ -152,9 +152,9 @@ That is the whole step. In order, it:
|
||||
`nvidia-smi` finds any, on CPU otherwise — and waits for it to install its
|
||||
engine
|
||||
9. closes llmgw's internal ports to everything but the cluster's networks
|
||||
10. fetches Qwen3Guard-Gen 0.6B, the guardrails' judge, and runs two
|
||||
replicas on the CPUs of nodes with room — this one, or the first that
|
||||
join ([guardrails.md](guardrails.md#a-judge-model) has one on a GPU too)
|
||||
10. fetches Qwen3Guard-Gen 0.6B, the guardrails' judge, and runs it on
|
||||
CPUs: one replica on up to four cores when this machine is the only
|
||||
node, two on two cores each on the nodes of a larger cluster ([guardrails.md](guardrails.md#a-judge-model) has one on a GPU too)
|
||||
11. fetches Qwen2.5 0.5B into the mirror and serves it on this node, so the
|
||||
first thing anyone opens `/chat` to find is a model that answers. A
|
||||
machine without room for it is told so within two minutes, and why.
|
||||
@@ -187,15 +187,43 @@ demonstration one:
|
||||
| Requests | every key limited (60 a minute, 200 000 tokens a minute) and priced, so budgets bind |
|
||||
| Guardrails | the `baseline` policy on every request: secrets in prompts and answers refused, card numbers masked, injection attempts recorded, and the judge refusing violence, crime, weapons, child abuse, privacy violations, hate, self-harm and sexual content, in prompts and answers |
|
||||
|
||||
The judge runs as two replicas, each on a CPU — about half a second for a
|
||||
short text on any CPU with AVX2 — and reads a streamed answer every 600
|
||||
characters. The policy fails **closed**: a request the judge cannot read
|
||||
within 5 seconds is refused with 503 rather than served unjudged, and
|
||||
`judge_unavailable` goes to the notification webhooks. Two replicas are what
|
||||
make that bearable; on one small machine, or a VM without AVX2 (see
|
||||
The judge runs on CPUs. A judgment of about 280 tokens took 2.3 s on four
|
||||
cores, 4.2 s on two and 8.0 s on one (a 4-core VM). It also reads a
|
||||
streamed answer every 600 characters. The policy fails **closed**: a request
|
||||
the judge cannot read within 5 seconds is refused with 503 rather than
|
||||
served unjudged, and `judge_unavailable` goes to the notification webhooks.
|
||||
So a judge gets cores of its own: on one machine one replica on up to four,
|
||||
and on a larger cluster two replicas of two cores each, spread over its nodes
|
||||
so that losing one leaves the other. On a machine of fewer than four cores,
|
||||
a busy one, or a VM without AVX2 (see
|
||||
[step 6](#6-setting-the-nodes-cpu-model-if-it-is-a-vm)), the judge is the
|
||||
first thing to fail, so `fail: open` — serve it, recorded as flagged — or
|
||||
`--no-guard-model` is the choice there. Defamation, specialized advice,
|
||||
first thing to fail. The choices there are a longer `latency_budget`,
|
||||
`fail: open` (serve the request, recorded as flagged), the judge on a GPU,
|
||||
or `--no-guard-model`.
|
||||
|
||||
init times the judge once it is up. If a judgment takes more than half the
|
||||
wait, or more than the wait when several come at once, it says so before the
|
||||
next steps, with what to do. The wait itself is not changed without being
|
||||
asked. `llmgw ctl guard judge check` does the same at any time:
|
||||
|
||||
```
|
||||
qwen3guard-0.6b, 2 replica(s) ready, a request waits 5.0 s for it
|
||||
alone: 2.4, 2.3, 2.3 s
|
||||
4 at once: slowest 6.1 s; about 0.7 judgments a second, so about 0.3 requests a second with short answers
|
||||
tight: a busy moment passes the budget
|
||||
```
|
||||
|
||||
In a three-machine install the judges are better on machines of their own.
|
||||
On a 4-core control plane that was also a node, 38 of 60 requests under load
|
||||
were refused for want of a judgment. With the judges on a node of their own
|
||||
(4 cores, two judges of two cores each), 2 were refused. The setup screen asks
|
||||
for up to two **judge machines** after the store's two, or give
|
||||
`--judge-node user@host` to `server init`, once for each. Each is added as a
|
||||
node labelled `llmgw.role: judge`, and the judge's profile selects that
|
||||
label, so the judges run only there and nothing else does. Four cores and
|
||||
8 GB are enough for two judges.
|
||||
|
||||
Defamation, specialized advice,
|
||||
intellectual property and elections are left out of what it refuses: they
|
||||
catch too much ordinary work. [guardrails.md](guardrails.md) has the rest.
|
||||
|
||||
@@ -363,7 +391,11 @@ by itself. Three things are worth knowing before you edit it:
|
||||
- `default_rpm`, `default_tpm` and `default_price` are what make limits and
|
||||
budgets mean anything. Without a price every request costs zero and a budget
|
||||
never binds; without the defaults, a project created without limits is
|
||||
metered by nothing at all.
|
||||
metered by nothing at all. A price may set `cached_per_1k` beside
|
||||
`prompt_per_1k`: the prompt tokens the engine or provider read from its
|
||||
cache (the answer's `cached_tokens`) are charged at it, the rest at the
|
||||
prompt rate. A provider that bills cached reads at a tenth is
|
||||
`{prompt_per_1k: 2500, cached_per_1k: 250, completion_per_1k: 10000}`.
|
||||
- `control.enabled: true` is what makes this a control plane while its catalog
|
||||
is empty. Without it, a file with no models is refused as having nothing to
|
||||
serve.
|
||||
@@ -844,6 +876,27 @@ Nothing to do here if you built the mirror in step 3 — the agent fetches what
|
||||
model's `source` names, and that source is your mirror. This section is for the
|
||||
arrangements where the files are already on the machine.
|
||||
|
||||
**Weights in a pickle format are refused.** `.bin`, `.pt`, `.pth`, `.ckpt` and
|
||||
`.pkl` are Python pickles, and loading one runs whatever it holds, as the
|
||||
engine's user, on the node — which is how the malicious models found on Hugging
|
||||
Face work. A model whose source is one, or a mirror version holding one, is
|
||||
refused when it is added and by the node before it fetches anything; vLLM and
|
||||
SGLang given a model by its Hugging Face id read only its safetensors. Take the
|
||||
repository's safetensors or GGUF. A model that has no other form, and whose
|
||||
files were checked, can say so:
|
||||
|
||||
```yaml
|
||||
- name: triposr
|
||||
source: http://mirror:8081/triposr/<rev>/model.ckpt
|
||||
allow_pickle: true
|
||||
```
|
||||
|
||||
A local path is judged by its name as any source is; a local directory is
|
||||
not looked into. What an engine downloads itself by a Hugging Face id, past
|
||||
the node — an adapter given by its id, a TensorRT-LLM model's — is refused
|
||||
when the model is added, unless it says `allow_pickle`: mirror its
|
||||
safetensors and give that instead.
|
||||
|
||||
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.
|
||||
@@ -1002,6 +1055,27 @@ A node with no location is placed as before, as a rack of its own.
|
||||
See [facility-aware-placement.md](facility-aware-placement.md) for power caps
|
||||
and holds.
|
||||
|
||||
### A card in the wrong slot
|
||||
|
||||
An x16 card in a slot wired x8 or x4, behind a riser, or in a slot of an
|
||||
older PCIe generation than the card's, links slower than it was built to and
|
||||
works otherwise. A model on the card still decodes at the card's own memory
|
||||
speed, but it loads slower, a replica split over several cards by PCIe is
|
||||
slower, and so is anything that spills to the host. Each card's agent reads
|
||||
how its link stands — the fastest generation card and slot allow together,
|
||||
and the lanes in use — against what the card could do, and the node says so
|
||||
among its warnings (the dashboard, the console's Nodes, `llmgw doctor`):
|
||||
|
||||
```
|
||||
GPU 1 (NVIDIA H100 80GB HBM3) linked at x8 of its x16 lanes: 31.5 GB/s where it
|
||||
could have 63.0. Check the slot (wired fewer lanes, or an older generation), a
|
||||
riser, or the BIOS's PCIe setting
|
||||
```
|
||||
|
||||
The generation a card reports now drops while it idles, to save power; what is
|
||||
compared is the fastest it may reach, which does not. `lspci -vv -s BUS-ID`
|
||||
shows the same: `LnkCap` is the card's, `LnkSta` the link's.
|
||||
|
||||
## 6. Setting the node's CPU model, if it is a VM
|
||||
|
||||
Proxmox defaults a VM to `kvm64`, which hides AVX, AVX2, FMA and even SSE4.2
|
||||
@@ -1319,6 +1393,26 @@ catalog, and a key rotated in the file is used within seconds. A model whose key
|
||||
file cannot be read is not offered. Everything else applies as to any model:
|
||||
prices and budgets, rate limits, usage records, and key allowlists.
|
||||
|
||||
A provider's limits are per key. With several keys, one answering 429 need not
|
||||
hold up the model: give them all, each with an optional weight (its share
|
||||
against the others'), in place of `api_key_file`:
|
||||
|
||||
```yaml
|
||||
external:
|
||||
url: https://api.openai.com
|
||||
model: gpt-4o-mini
|
||||
api_key_files:
|
||||
- {file: /etc/llmgw/secrets/openai-1.key, weight: 2}
|
||||
- {file: /etc/llmgw/secrets/openai-2.key}
|
||||
```
|
||||
|
||||
Each key is an endpoint of its own, `external:cloud-mini#1`, `#2`. A key that
|
||||
answers 429 is passed over while its breaker is open, and the request goes to
|
||||
another. One that answers 401 (revoked, expired, mistyped) is passed over too,
|
||||
and `external_key_refused` goes to the notification webhooks, naming the
|
||||
endpoint and never the key. The usage records say which key answered by the
|
||||
same name. A key whose file cannot be read is left out, and the others stay.
|
||||
|
||||
**Its prompts leave the cluster.** Decide who may send them: a key or project
|
||||
whose allowlist leaves the external model out cannot reach it, as a fallback or
|
||||
by name. The cluster page and `ctl models` mark it `external`.
|
||||
@@ -1535,6 +1629,25 @@ 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:
|
||||
|
||||
```
|
||||
llmgw ctl model context qwen-7b 32768 # tokens a request
|
||||
llmgw ctl model context qwen-7b 32768 --slots 4 # llama.cpp: four at once
|
||||
```
|
||||
|
||||
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
|
||||
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
|
||||
settings is changed there (`llmgw ctl config edit`).
|
||||
|
||||
### Trying new weights on some of the traffic first
|
||||
|
||||
When a model's repository publishes new weights, `llmgw ctl model update`
|
||||
@@ -1909,6 +2022,18 @@ So "why did qwen take four minutes to start" is one trace: find `replica start`
|
||||
with `llmgw.model` = qwen, and the node's spans beneath it say whether the
|
||||
time went to the download, the launch or the engine loading.
|
||||
|
||||
An inference request and each `upstream` attempt also carry the
|
||||
[OpenTelemetry GenAI conventions](https://opentelemetry.io/docs/specs/semconv/gen-ai/),
|
||||
so a tool that reads them — Grafana, Jaeger, Langfuse, Arize Phoenix — shows
|
||||
llmgw's calls as model calls without a dashboard made for llmgw:
|
||||
`gen_ai.operation.name` (`chat`, `text_completion`, `embeddings`),
|
||||
`gen_ai.request.model`, `gen_ai.request.max_tokens`, `temperature` and `top_p`
|
||||
when the client set them, `gen_ai.conversation.id` (the session),
|
||||
`gen_ai.response.model`, and `gen_ai.usage.input_tokens`, `output_tokens` and
|
||||
`cache_read.input_tokens`. On an `upstream` span `gen_ai.provider.name` is the
|
||||
engine that answered (`vllm`, `llamacpp`, `external`, …). What was asked and
|
||||
what was answered are never in a span.
|
||||
|
||||
A node follows the control plane's decision: a start the control plane kept is
|
||||
traced on the node whatever the agent's own `sample_ratio`, and one it sampled
|
||||
out is not; the agent's ratio decides only for what arrives with no trace, as
|
||||
@@ -2073,8 +2198,10 @@ llmgw keys cache --project Acme/ml/train --off
|
||||
- **Policies still apply:** the limits are checked and the guardrails judge
|
||||
the request before the cache is looked in, and the output policy judges a
|
||||
reused answer as it would a new one. A request continuing a stored
|
||||
conversation, or under a `no_store` policy, is never answered from the
|
||||
cache nor kept in it.
|
||||
conversation, under a `no_store` policy, or whose redacted values are put
|
||||
back in its answer (`restore: true`) is never answered from the cache nor
|
||||
kept in it: redacted, two callers' requests read the same, and the answer
|
||||
has the first caller's values in it.
|
||||
- **What a hit costs:** nothing. It is recorded as a request — status 200,
|
||||
the model, the key, the rate limit's count — with no tokens and no cost,
|
||||
marked as a cache hit (`llmgw ctl request <id>` says so), so it spends no
|
||||
@@ -2094,8 +2221,46 @@ router:
|
||||
max_entry_bytes: 2097152 # 2 MB
|
||||
```
|
||||
|
||||
`llmgw_cache_requests_total{model,result}` counts hits, misses and
|
||||
bypasses, `llmgw_cache_bytes` and `llmgw_cache_entries` what is held. The
|
||||
### Similar questions
|
||||
|
||||
A help desk asked "how do I reset my password" in forty wordings asks the
|
||||
model forty times under the exact cache. `--similar` also gives a chat
|
||||
question the answer to one like it:
|
||||
|
||||
```
|
||||
llmgw keys cache --project Acme/support/desk --ttl 24h --similar 0.95
|
||||
```
|
||||
|
||||
```yaml
|
||||
router:
|
||||
cache:
|
||||
embedding_model: bge-m3 # an embedding model in the catalog
|
||||
```
|
||||
|
||||
- **What is compared:** the last user message, embedded by
|
||||
`router.cache.embedding_model`, against the questions already answered
|
||||
in the same conversation — the same system prompt, the same earlier turns,
|
||||
tools, `response_format` and sampling settings, the same model and
|
||||
project. A like question in another conversation, after a tool's result,
|
||||
or with a picture, a sound or a file in it, is not taken for this one. The exact cache is asked first, and an
|
||||
exact hit embeds nothing.
|
||||
- **How alike:** the cosine similarity of the two embeddings, at least the
|
||||
project's `--similar`. 0.95 is strict; below 0.9 different questions start
|
||||
to meet. A question that differs only in a number or a name ("2+2" and
|
||||
"2+3") can be closer than that, so a project whose questions turn on
|
||||
figures should not use it. A question over 2,000 characters is reused only
|
||||
when it is the same.
|
||||
- **Said:** `x-llmgw-cache: similar`, with `x-llmgw-cache-similarity`
|
||||
and `Age`. Recorded and charged as a hit. The same rules as a hit decide
|
||||
which answers are reused and kept; guardrails judge the request before.
|
||||
- **The embedding** is asked of the model by the gateway on its own account,
|
||||
as a judge is, within 2 seconds; every miss waits for it. Without the
|
||||
model, or when it does not answer, only the same request is reused. The
|
||||
vectors are kept in memory with their answers and counted in
|
||||
`router.cache.max_bytes`.
|
||||
|
||||
`llmgw_cache_requests_total{model,result}` counts hits, similar hits, misses
|
||||
and bypasses, `llmgw_cache_bytes` and `llmgw_cache_entries` what is held. The
|
||||
console has it as **Cache answers…** on a project.
|
||||
|
||||
## Batches
|
||||
@@ -2204,6 +2369,7 @@ triposr`. What that adds is this entry, and its workflow is built in:
|
||||
```yaml
|
||||
- name: triposr
|
||||
source: hf:stabilityai/TripoSR/model.ckpt
|
||||
allow_pickle: true # published as this checkpoint only
|
||||
comfyui:
|
||||
workflow: triposr # built in; a model of your own names a JSON file on every control plane
|
||||
output: meshes
|
||||
@@ -2375,8 +2541,31 @@ lost: a standby leads within seconds, Patroni promotes the standby database
|
||||
if it was the primary, and the floating address follows whichever leads. The
|
||||
mirror's management API answers the three control planes' addresses, not
|
||||
only its own machine's, so a standby that comes to lead can still fetch and
|
||||
add models. The mirror itself stays on the first machine: while it is down,
|
||||
models the nodes already have keep serving, and new ones wait for it.
|
||||
add models.
|
||||
|
||||
**A second mirror.** The mirror is on the first machine, and without a
|
||||
second one, models the nodes already have keep serving while that machine
|
||||
is down, and new ones wait for it. With a second, the cluster carries on.
|
||||
- **Setting it up:** the screens offer one on either database machine
|
||||
("A second mirror"); by command, `--second-mirror 1` (or `2`). For an
|
||||
install that has one mirror, run this on the mirror's machine:
|
||||
|
||||
```
|
||||
sudo llmgw mirror add-peer ubuntu@10.0.0.7 --control-plane 10.0.0.5 --control-plane 10.0.0.6 --control-plane 10.0.0.7
|
||||
```
|
||||
- **Kept in step:** each mirror reads the other every thirty seconds and
|
||||
copies what it lacks, keeping a copy only if its checksum matches.
|
||||
- A model removed from one is removed from the other, even if that one
|
||||
was down at the time and comes back later.
|
||||
- A model added again after it was removed stands on both.
|
||||
- Neither mirror leads.
|
||||
- **Control planes:** they name both (`mirror.peers` in the cluster's
|
||||
settings) and send the mirror's management calls to the first that
|
||||
answers.
|
||||
- **Nodes:** while the mirror a model's address names is down, a node is
|
||||
given the same file on the other.
|
||||
- **Disk:** the second machine needs room for every model, as the first
|
||||
does.
|
||||
|
||||
## Two control planes
|
||||
|
||||
@@ -2460,6 +2649,89 @@ the same files and keys as `locales/en/`, and its `_meta.*` entries saying how
|
||||
it writes numbers and dates. The tests refuse a catalogue that lacks a key a
|
||||
page uses.
|
||||
|
||||
## The company's MCP servers
|
||||
|
||||
llmgw's `/mcp` can serve the company's own MCP servers beside its own tools.
|
||||
Each server, a ticket system, a file server or a database, is registered once.
|
||||
Its credentials stay on the control planes, and people's agents are given only
|
||||
`/mcp` and their llmgw key.
|
||||
|
||||
```
|
||||
llmgw ctl mcp add jira --url https://jira.internal/mcp \
|
||||
--description "Acme Jira" --secret-file Authorization=/root/jira-token
|
||||
llmgw ctl mcp test jira # its tools, asked with its credentials
|
||||
llmgw keys limits --scope team --id Acme/support --tools 'jira.*'
|
||||
```
|
||||
|
||||
- **Credentials:**
|
||||
- `--secret-file NAME=FILE` reads a header's value once and keeps it in
|
||||
the store, sealed by the master key, so no file has to be on each
|
||||
control plane.
|
||||
- `--header-ref NAME=REF` reads it at each call from a file, `env:`,
|
||||
`vault:` or `store:` instead.
|
||||
- `--header NAME=VALUE` is for what is not secret.
|
||||
- **Who may call what:** tools are named `server.tool` and granted as
|
||||
patterns (`jira.*`, `db.query`) at any level of the tenancy tree, as
|
||||
models are.
|
||||
- A level that names any narrows what is above it: a tool is seen where
|
||||
every level above that names tools names it too. A team under an
|
||||
organization that names `hr.*` sees no `jira.*` whatever the team is
|
||||
given, until the organization names it as well.
|
||||
- Unlike models, **none granted anywhere is none**: a tool reaches into a
|
||||
company system, so it is granted, never assumed.
|
||||
- `keys limits --tools` writes a level's list whole, every server's
|
||||
together; an empty list (`--tools ''`) closes the level to every tool.
|
||||
The console's **MCP servers** screen and the `/admin/mcp` page grant
|
||||
and take back one server's tools: a level given its first list starts
|
||||
from the nearest list above it, so it keeps what it saw; one left
|
||||
naming none goes back to naming none, or stays closed when a level
|
||||
above names tools, so taking one server away opens it to no other. They
|
||||
say when a level above keeps the granted tools out of reach.
|
||||
- **The server is told who calls:** `X-Llmgw-Key`, and `X-Llmgw-User` and
|
||||
`X-Llmgw-Project` where there are some. It is called with llmgw's
|
||||
credentials, never the caller's key.
|
||||
- **Bounds:** a call has 60 seconds (`--timeout`) and an answer 4 MB. A
|
||||
server's progress is passed on to a client that takes a stream. `--rate N`
|
||||
allows each caller N calls a minute to a server's tools.
|
||||
- **Guardrails, both ways:** the caller's policies read a tool's answer as
|
||||
the prompt it becomes.
|
||||
- An instruction planted in a ticket is caught as a pasted one would be
|
||||
(`injection: block`).
|
||||
- Personal data is redacted or refused as the policy says. A refused
|
||||
answer does not reach the agent.
|
||||
- The arguments are read before they leave, so a secret pasted into one
|
||||
does not reach the company system.
|
||||
- **A server that keeps failing:** five calls failing in a row take a server
|
||||
out for a minute. Its tools leave the list, and a call is answered at once.
|
||||
- **Record:** every call is in `llmgw ctl audit`, with its arguments' size
|
||||
and a digest, never their text. `llmgw_mcp_tool_calls_total` and
|
||||
`llmgw_mcp_tool_call_seconds` count them by server, tool and outcome.
|
||||
- **Changes:** a server added, changed or removed on one control plane is
|
||||
served by every one within half a minute; its tool list is read again
|
||||
every five minutes. `--disabled` keeps a server but serves none of its
|
||||
tools.
|
||||
|
||||
**This cluster's own, read only.** `llmgw ctl mcp add llmgw --url
|
||||
llmgw:admin` registers the cluster itself as a server: tools that say what
|
||||
`llmgw ctl` does, and change nothing. It is answered in the control plane's
|
||||
own process, with no address and no token to give.
|
||||
|
||||
| Tool | Says |
|
||||
|---|---|
|
||||
| `llmgw.cluster_status` | control planes, store, nodes, models against what they asked for, GPUs, what is unhealthy |
|
||||
| `llmgw.list_nodes` | each node's state, GPUs, load, fabrics, agent version and engines |
|
||||
| `llmgw.list_models` | each model's replicas ready against wanted, profiles, why one is parked |
|
||||
| `llmgw.list_replicas` | each replica's shards: node, GPUs, state (`model` for one model's) |
|
||||
| `llmgw.explain_placement` | why a model is not placed, node by node |
|
||||
| `llmgw.node_logs` | a node's last agent log lines, or an engine's (`source`) |
|
||||
|
||||
Grant it as any other: `llmgw keys limits --scope key --id KEY-ID --tools
|
||||
'llmgw.*'`. It is the same as an administrator's view, so grant it to the
|
||||
people who would have `ctl`.
|
||||
|
||||
Servers over HTTP only for now (the streamable HTTP transport). A server
|
||||
that runs as a local process (stdio) is planned.
|
||||
|
||||
## Single sign-on
|
||||
|
||||
People can sign in through your identity provider — Keycloak, Entra ID,
|
||||
@@ -2467,8 +2739,10 @@ Google, Okta, Authentik, anything that speaks OpenID Connect.
|
||||
|
||||
1. Register a client there with the redirect address
|
||||
`https://<gateway>/login/oidc/callback`.
|
||||
2. Put its secret in a file only root reads.
|
||||
3. Add to `server.yaml`:
|
||||
2. Put its secret in a file the llmgw user reads, on every control plane.
|
||||
3. Add this to the cluster's settings with `llmgw ctl config edit`. On a
|
||||
cluster with a store, `auth` is the cluster's: the same block in
|
||||
`server.yaml` is not read, and the log says so.
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
@@ -2517,6 +2791,99 @@ and point it at `https://<gateway>/scim/v2` with that token as the bearer.
|
||||
join and leave them. Membership in a group is removed when someone leaves
|
||||
it.
|
||||
|
||||
### Entra ID
|
||||
|
||||
The console sets this up: `sudo llmgw`, People → Sign-in, **E**. What
|
||||
follows is what it writes, and what to do in Entra.
|
||||
|
||||
Entra ID is an OpenID Connect provider like the others, with two things to
|
||||
set in the app registration:
|
||||
|
||||
1. **Redirect URI:** under Authentication, add a Web platform with
|
||||
`https://<gateway>/login/oidc/callback`. The issuer is
|
||||
`https://login.microsoftonline.com/<tenant-id>/v2.0`.
|
||||
2. **Groups in the token:** under Token configuration, add a groups claim.
|
||||
Choose **Groups assigned to the application**, and assign the groups you
|
||||
map in `roles` and `memberships` to the enterprise application. Entra
|
||||
sends groups by object id unless you choose to emit names
|
||||
(`sAMAccountName` for groups synced from AD), so write them the same way
|
||||
in the mapping.
|
||||
|
||||
Entra leaves the groups out of the token for someone in more than 200 of
|
||||
them and sends a pointer to Graph instead. llmgw then keeps that person's
|
||||
role and memberships as they were, rather than reading it as no groups, and
|
||||
logs an error saying so. "Groups assigned to the application" avoids this.
|
||||
So does SCIM below, which Entra also speaks: in the enterprise application
|
||||
under Provisioning, set the tenant URL to `https://<gateway>/scim/v2` and
|
||||
the secret token to the contents of `auth.scim.token_file`.
|
||||
|
||||
## Active Directory
|
||||
|
||||
With an on-premises Active Directory, people sign in on the gateway's own
|
||||
form with their AD username (`ada`, `CORP\ada`, `ada@corp.example`) and
|
||||
password. The gateway checks them with the domain controllers over LDAPS.
|
||||
|
||||
The console sets this up and tries it first: `sudo llmgw`, People →
|
||||
Sign-in, **D**. It keeps the service account's password in the store
|
||||
(`bind_password_file: store:auth/ldap-bind`) and writes the CA into the
|
||||
settings (`ca:`), so nothing has to be copied to each control plane.
|
||||
By hand it is this block in the cluster's settings (`llmgw ctl config
|
||||
edit`), where files it names must then be on every control plane:
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
enabled: true
|
||||
ldap:
|
||||
urls: [ldaps://dc1.corp.example, ldaps://dc2.corp.example] # tried in order
|
||||
ca_file: /etc/llmgw/corp-ca.pem # the domain's CA, when not in the system's
|
||||
bind_dn: CN=svc-llmgw,OU=Service,DC=corp,DC=example
|
||||
bind_password_file: /etc/llmgw/secrets/ldap-bind # root-only
|
||||
base_dn: OU=Staff,DC=corp,DC=example # where people are
|
||||
user_filter: (memberOf:1.2.840.113556.1.4.1941:=CN=llmgw-users,OU=Groups,DC=corp,DC=example)
|
||||
label: Acme # "Sign in with your Acme account"
|
||||
create_accounts: true
|
||||
roles: # by group CN or full DN; nested groups count
|
||||
admin: [llmgw-admins]
|
||||
operator: [llmgw-ops]
|
||||
memberships:
|
||||
ml-engineers: "team:Acme/ml"
|
||||
sync_interval: 15m # negative turns the sync off
|
||||
```
|
||||
|
||||
- **Service account:** an ordinary domain user is enough: it only reads.
|
||||
It finds the person and reads their groups. The person's password is
|
||||
checked by binding as them, and is never stored.
|
||||
- **Encryption:** `ldaps://`, or `ldap://` with `start_tls: true`.
|
||||
Plaintext LDAP is refused, since passwords would cross the network.
|
||||
- **Certificate:** the domain controller's certificate must name it in
|
||||
its subject alternative names: a name in the CN only is refused, as are
|
||||
certificates with a negative serial number. Samba's own autogenerated
|
||||
certificate is both, so a Samba DC needs one from a CA (`tls certfile`
|
||||
in smb.conf). A CA file a control plane cannot read turns directory
|
||||
sign-in off on that control plane, said in its log. Local accounts still
|
||||
sign in.
|
||||
- **Who may sign in:** enabled user objects under `base_dn` that match
|
||||
`user_filter`. The example admits members of `llmgw-users`, nested groups
|
||||
included.
|
||||
- **Accounts:** an account is tied to the person's `objectGUID`, so a changed
|
||||
mail or name is still the same account. An account that already exists
|
||||
with the person's mail is taken over. Without `create_accounts`, only
|
||||
people who already have an account can sign in. Local accounts, such as
|
||||
the administrator `server init` made, keep signing in with their own
|
||||
password.
|
||||
- **Sync:** every `sync_interval` the leader reads the directory.
|
||||
- Someone disabled or deleted in AD is suspended and their sessions
|
||||
end.
|
||||
- When they are enabled again they are reinstated. An account an
|
||||
administrator suspended by hand stays suspended.
|
||||
- Roles follow groups, and memberships the directory gave are taken away
|
||||
when the person leaves the group.
|
||||
- A sync that would suspend more than half of the directory's accounts is
|
||||
refused and logged, rather than locking everyone out over a wrong
|
||||
`base_dn` or filter.
|
||||
- **Passwords:** a person's password is changed in AD. The gateway sends no
|
||||
reset link for these accounts.
|
||||
|
||||
## Secrets in Vault
|
||||
|
||||
Every setting that names a file holding a secret — the store's `@address`,
|
||||
@@ -3088,6 +3455,8 @@ requests. On a slow model, set `timeouts.generation` higher.
|
||||
|
||||
- `docs/choosing-models.md` — which models to run and how many, if the answer
|
||||
to "we have GPUs, now what" is not already obvious
|
||||
- `docs/large-models.md` — a model on several cards or several nodes: how it
|
||||
is split, what each way needs of a card, and bringing one up on a fabric
|
||||
- `docs/data.md` — what is stored and what leaves the machine, for the review
|
||||
that happens before this is allowed near anyone's documents
|
||||
- `docs/reference.md` — every configuration key, generated from the source, so
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# llmgw and Kong
|
||||
|
||||
For a site that already runs Kong. The two meet over plain HTTP: Kong in
|
||||
front of llmgw, as one more API it governs, or Kong behind llmgw, as an
|
||||
OpenAI-compatible provider. Nothing of either is built into the other.
|
||||
|
||||
Tried with Kong Gateway 3.9 (`kong:3.9`, the last open-source release; 3.10
|
||||
and later need a Kong licence) in DB-less mode, in front of llmgw.
|
||||
|
||||
## Kong in front of llmgw
|
||||
|
||||
Two ways, both tried.
|
||||
|
||||
**A plain route.** Kong forwards the request as it came. Tried with chat
|
||||
completions, streamed and not; llmgw's other APIs — Anthropic's
|
||||
`/v1/messages`, `/v1/responses`, embeddings, pictures — go the same way,
|
||||
since Kong does not read them. Kong adds its own authentication, rate limits
|
||||
and logging; llmgw still checks the key, the budget and the guardrails.
|
||||
|
||||
```yaml
|
||||
_format_version: "3.0"
|
||||
services:
|
||||
- name: llmgw
|
||||
url: https://llm.example.com # llmgw's client port
|
||||
routes:
|
||||
- name: llmgw
|
||||
paths: [/llmgw] # /llmgw/v1/chat/completions → /v1/chat/completions
|
||||
```
|
||||
|
||||
**Kong's `ai-proxy` plugin**, for Kong's own token statistics on a chat
|
||||
route. llmgw is an OpenAI provider whose `upstream_url` is llmgw:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
- name: llmgw-chat
|
||||
url: https://llm.example.com
|
||||
routes:
|
||||
- name: chat
|
||||
paths: [/chat]
|
||||
plugins:
|
||||
- name: ai-proxy
|
||||
config:
|
||||
route_type: llm/v1/chat
|
||||
logging: {log_statistics: true, log_payloads: false}
|
||||
model:
|
||||
provider: openai
|
||||
name: qwen-coder # a model llmgw serves
|
||||
options:
|
||||
upstream_url: https://llm.example.com/v1/chat/completions
|
||||
```
|
||||
|
||||
**Keys.** Without an `auth` block, `ai-proxy` passes the client's
|
||||
`Authorization` header on: each team keeps its own llmgw key, and llmgw's
|
||||
budgets, limits, guardrails and usage stay per team. With
|
||||
`auth: {header_name: Authorization, header_value: "Bearer sk-llmgw-…"}` Kong
|
||||
sends one key of its own instead, and llmgw sees every caller as that key —
|
||||
then budgets per team are Kong's to keep.
|
||||
|
||||
**What Kong counts.** For a whole answer Kong reads llmgw's `usage` and
|
||||
records prompt and completion tokens as llmgw did. For a streamed answer
|
||||
Kong 3.9 records an estimate of the prompt tokens and no completion tokens,
|
||||
even with `stream_options.include_usage` set — the usage chunk reaches it
|
||||
and is not read. llmgw's own accounting counts both; use its usage export
|
||||
(`llmgw ctl usage export`) or `/admin/usage` for figures to bill by.
|
||||
|
||||
**Timeouts.** A long answer from a model still loading outlasts Kong's
|
||||
60-second defaults: set the service's `read_timeout` to the time a model may
|
||||
take to start, as for nginx in [install.md](install.md).
|
||||
|
||||
## Kong behind llmgw
|
||||
|
||||
Not tried here. Kong's AI routes speak OpenAI's API, so llmgw reaches models Kong fronts as
|
||||
it reaches any provider: a model whose source is `external`.
|
||||
|
||||
```yaml
|
||||
- name: kong-gpt
|
||||
source: external
|
||||
external:
|
||||
url: https://kong.example.com/openai # Kong's route, without /v1
|
||||
model: gpt-4o-mini
|
||||
api_key_file: /etc/llmgw/secrets/kong-consumer.key
|
||||
```
|
||||
|
||||
The key is the one Kong's own authentication asks of a consumer. As a
|
||||
fallback (`fallbacks: [kong-gpt]`) it is used only when the cluster has no
|
||||
room.
|
||||
|
||||
## Which to choose
|
||||
|
||||
Kong in front when the site's API policy says every API goes through Kong:
|
||||
llmgw is then one more upstream, and its per-team accounting still holds
|
||||
when each team keeps its own key. Kong behind when the site already reaches
|
||||
outside providers through Kong and llmgw should too. llmgw does not need
|
||||
Kong for anything it does itself — keys, budgets, guardrails, routing to
|
||||
its own replicas.
|
||||
@@ -0,0 +1,145 @@
|
||||
# Large models: several cards, several nodes
|
||||
|
||||
A model too big for one card runs on several. llmgw chooses how from the
|
||||
model's own figures and the cluster as it is; this page is the rules it
|
||||
follows, the commands, and what to look at when a first one is brought up
|
||||
on new hardware.
|
||||
|
||||
## How a model is split
|
||||
|
||||
In order, the first that fits:
|
||||
|
||||
1. **One card.** Nothing to split.
|
||||
2. **Several cards of one node, tensor parallel.** Every layer is cut
|
||||
across the cards, so they talk for every token: they must be on one
|
||||
NVLink domain. The agent reads `nvidia-smi topo -m` and gives a replica
|
||||
the cards with the most NVLink between them
|
||||
([install.md](install.md#nvlink-if-your-gpus-have-it)).
|
||||
3. **Several nodes, pipeline parallel between them.** Each node holds a
|
||||
run of layers on all its cards (tensor parallel inside it), and only a
|
||||
layer's output crosses to the next node. The nodes must share one
|
||||
InfiniBand or RoCE fabric: a replica never spans two, and never a node
|
||||
with none. Any number of nodes, not only two or four.
|
||||
|
||||
The plan takes the fewest cards, then the fewest nodes, then the better
|
||||
precision up to eight bits.
|
||||
|
||||
**Tensor width and the KV heads.** The cards split the model's KV heads
|
||||
among them, or past their number each card holds a copy of one. So the
|
||||
width divides the KV heads or is a multiple of them: a model with 8 KV
|
||||
heads runs 2, 4, 8 or 16 wide, not 12. Qwen3-235B has 4 and runs on 8.
|
||||
|
||||
**Mixture of experts.** A model of experts (Qwen3-235B-A22B, DeepSeek-V3,
|
||||
Llama 4, Mixtral) on more than one card is served with vLLM's
|
||||
`--enable-expert-parallel`: each card holds whole experts rather than a
|
||||
slice of every one, which is less traffic per token. It is read from
|
||||
`config.json` (`num_experts`, `n_routed_experts`, `num_local_experts`).
|
||||
|
||||
**When it does not fit.** A smaller precision first: an FP8 or AWQ
|
||||
repository of the same model, or a GGUF quantisation on llama.cpp if one
|
||||
node's cards hold it. Then more nodes. `llmgw ctl model plan REPO
|
||||
--precision bf16,fp8` shows each precision's fit from the model's config.
|
||||
|
||||
## What a way needs
|
||||
|
||||
Each way the plan offers says what a replica needs of each card, part by
|
||||
part, and of all its cards together:
|
||||
|
||||
```
|
||||
$ llmgw ctl model plan meta-llama/Llama-3.3-70B-Instruct --format safetensors --context 32768
|
||||
FILE SIZE BEST WAY A CARD OF ALL CARDS NOW
|
||||
original 131 GB 4 × h100-80g 37.4 GB 72 GB 150 GB free
|
||||
|
||||
Best: original on 4 × h100-80g: 37.4 GB a card (weights 32.9 + context 2.5 + engine 2.0) of 72 usable; 150 GB on 4 cards.
|
||||
The context costs 0.31 GB per 1000 tokens, the whole model (--context to plan another).
|
||||
```
|
||||
|
||||
- **weights:** the files, divided over the cards.
|
||||
- **context:** the KV cache for the context planned, divided likewise.
|
||||
- **engine:** the engine's own: 2 GB and a fiftieth of the weights for vLLM.
|
||||
- **usable:** 90% of a card; the rest is the driver's and fragmentation's.
|
||||
|
||||
The console's **Serve a model** shows the same for every way a file runs.
|
||||
A context longer than planned is checked against it before anything is
|
||||
fetched.
|
||||
|
||||
## Serving one
|
||||
|
||||
Without flags the plan's best way is taken:
|
||||
|
||||
```
|
||||
llmgw ctl model serve Qwen/Qwen3-235B-A22B-Instruct-2507-FP8 --format safetensors --context-length 32768
|
||||
```
|
||||
|
||||
Or a way given by hand, for instance two nodes of eight:
|
||||
|
||||
```
|
||||
llmgw ctl model serve meta-llama/Llama-3.3-70B-Instruct --format safetensors \
|
||||
--gpu-class h100-80g --gpus 8 --nodes 2 --env NCCL_DEBUG=INFO
|
||||
```
|
||||
|
||||
`--gpus` is the cards on each node and `--nodes` the nodes. The pipeline
|
||||
is the nodes and the tensor width the cards of each.
|
||||
|
||||
`--env KEY=VALUE` goes into the engine's environment on every node:
|
||||
`NCCL_DEBUG=INFO` while bringing a cluster up, `NCCL_IB_*` to steer it.
|
||||
Only the variables engines are tuned with are taken. llmgw already sets
|
||||
these:
|
||||
- `NCCL_IB_HCA` to the fabric's adapters;
|
||||
- `NCCL_IB_GID_INDEX` on RoCE;
|
||||
- `NCCL_SOCKET_IFNAME` to the management network;
|
||||
- for an NVLink domain spanning nodes, `NCCL_MNNVL_ENABLE`.
|
||||
|
||||
The job's first step says what it needs of each card. The weights then
|
||||
come to the mirror once and from it to each node. The startup timeout (20
|
||||
minutes unless the profile's `startup_timeout` says otherwise) runs from
|
||||
when a node's download last moved. So a model of hundreds of gigabytes is
|
||||
not failed while it is still arriving, and one whose download stops is
|
||||
failed that long after.
|
||||
|
||||
## Watching it come up
|
||||
|
||||
| What | Where |
|
||||
|---|---|
|
||||
| The replica and its shards, one per node, each with its download | `llmgw ctl replicas` |
|
||||
| Why it is not placed | `llmgw ctl explain-placement MODEL` |
|
||||
| A node's engine output (NCCL's lines with `NCCL_DEBUG=INFO`) | `llmgw ctl logs NODE --source SHARD` |
|
||||
| The bandwidth between two nodes, rail by rail | `llmgw ctl fabric-test NODE NODE` |
|
||||
| Each node's GPUs, NVLink and adapters | `llmgw doctor` on the node; its page under /admin |
|
||||
| Every step of a start, across nodes | the `replica start` trace ([install.md](install.md)) |
|
||||
|
||||
With `NCCL_DEBUG=INFO`, NCCL says which transport it took between nodes:
|
||||
`NET/IB` is the fabric. `NET/Socket` means it fell back to TCP, and the
|
||||
model will run, slowly.
|
||||
|
||||
## Bringing it up on new hardware
|
||||
|
||||
A first run on a fabric, in order. Each step is worth its output if
|
||||
something goes wrong.
|
||||
|
||||
1. **The nodes.** Add each with its fabric:
|
||||
`llmgw node add host --fabric ib-a:mlx5_0,mlx5_1`. Then
|
||||
`llmgw ctl nodes` shows every node ready, on `ib-a`.
|
||||
2. **The fabric.** `llmgw ctl fabric-test node1 node2`: each rail at its
|
||||
rated speed (a 400 Gb/s rail near 45 GB/s).
|
||||
3. **One node, several cards.** A dense 70B at bf16 on four or eight
|
||||
cards of one node, by the plan:
|
||||
`llmgw ctl model serve meta-llama/Llama-3.3-70B-Instruct --format safetensors`.
|
||||
Ready, then a question answered.
|
||||
4. **Two nodes.** The same model forced over two:
|
||||
`--gpu-class CLASS --gpus 8 --nodes 2 --env NCCL_DEBUG=INFO`, under
|
||||
another `--name`. Ready, answering, and `NET/IB` in both shards' logs.
|
||||
5. **Experts.** A mixture-of-experts model on one node's eight cards,
|
||||
for instance `Qwen/Qwen3-235B-A22B-Instruct-2507-FP8`: its profile
|
||||
carries `--enable-expert-parallel` (`llmgw ctl models`, or its
|
||||
definition from the admin API).
|
||||
6. **A node lost.** With the two-node replica serving, stop one node's
|
||||
agent (`systemctl stop llmgw-agent`): the replica fails and is placed
|
||||
again once the node is back or another has room. Nothing is left
|
||||
holding cards.
|
||||
|
||||
For each, worth keeping:
|
||||
- the time from serve to ready;
|
||||
- tokens a second from `llmgw ctl bench`;
|
||||
- the plan's per-card figure against what `nvidia-smi` shows once loaded;
|
||||
- anything it said that was wrong.
|
||||
+13
-3
@@ -54,10 +54,20 @@ on the page that creates it and never again.
|
||||
|
||||
### Connecting an editor
|
||||
|
||||
The account page's **Connect your editor** makes a key for Continue, Codex or
|
||||
Cline and shows what the tool needs with that key, this gateway's address and a
|
||||
model already in it:
|
||||
The account page's **Connect your editor** makes a key for VS Code, Continue,
|
||||
Codex or Cline and shows what the tool needs with that key, this gateway's
|
||||
address and a model already in it:
|
||||
|
||||
- **VS Code** — llmgw's own extension, downloaded from the gateway at
|
||||
`/setup/vscode/llmgw.vsix` with this gateway's address already in it. It
|
||||
offers every chat model the key may use to VS Code itself: the chat's model
|
||||
picker, agent mode, and any extension that asks VS Code for a language model
|
||||
(Cline's "VS Code LM API" provider among them), with no setup of their own.
|
||||
**llmgw: Sign In** in VS Code opens this page with VS Code chosen; the page's
|
||||
**Open in VS Code** link hands the key back, and the extension keeps it in
|
||||
VS Code's secret storage. A link the extension did not ask for is confirmed
|
||||
first, with the gateway's address shown, since it decides where code is sent.
|
||||
**llmgw: Enter API Key** takes a pasted one instead.
|
||||
- **Continue** — an assistant file of its own, `~/.continue/assistants/llmgw.yaml`,
|
||||
so their `config.yaml` is not touched.
|
||||
- **Codex** — a provider and a profile in `~/.codex/config.toml`, run with
|
||||
|
||||
+30
-2
@@ -8,7 +8,7 @@ explains every choice.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **A control plane** | One machine, or three for high availability (the store replicated, either one able to take over). Linux, x86-64 or ARM64: Debian 12, Ubuntu 22.04+ or RHEL 10. No GPU needed; 2 GB RAM, 20 GB disk each — 8 GB RAM when it is also a node running the starter model and the two judges. |
|
||||
| **A control plane** | One machine, or three for high availability (the store replicated, either one able to take over). Linux, x86-64 or ARM64: Debian 12, Ubuntu 22.04+ or RHEL 10. No GPU needed; 2 GB RAM, 20 GB disk each — 12 GB RAM when it is also a node running the starter model and the two judges, which setup checks before it installs anything; four cores or more, or the judges on machines of their own, keep a judgment within its wait. |
|
||||
| **GPU nodes** (optional to start) | The same Linux, with the NVIDIA driver installed. vLLM needs driver 525 or newer and a Turing GPU or newer; a V100 gets an older vLLM. |
|
||||
| **root** | on the control plane. On the other two of a three-machine setup, `sudo` (its password is asked for if it needs one); on nodes, `sudo` without a password. |
|
||||
| **SSH** | from the control plane to the other machines. The setup screens take a key, the ssh agent or a password; `node add` uses ssh keys. A machine nothing may log into joins with a code instead (`llmgw join`). |
|
||||
@@ -146,7 +146,35 @@ agent is stopped.
|
||||
## 4. Add a model
|
||||
|
||||
Open `/admin/mirror` and choose **Explore models**. It lists what is popular on
|
||||
Hugging Face, showing only what your nodes can run.
|
||||
Hugging Face, showing only what your nodes can run: judged by the cluster as it
|
||||
is now — the largest card and its compute capability, the most memory on one
|
||||
node — so a model too big for them, in a form those cards lack (FP8 needs 8.9),
|
||||
or in neither GGUF nor safetensors is left out, and the page says how many.
|
||||
Without GPUs it lists GGUF only. Untick **only what these nodes can run** to
|
||||
see everything. On a model's page a file too big for the cluster is shown
|
||||
without a **Fetch** button, with what it would need.
|
||||
|
||||
The **CPU tok/s** column is about how fast one reply would come on the CPU of
|
||||
the fastest node whose memory holds the model at Q4_K_M; a model's page gives
|
||||
it for each GGUF file by its own size. Every node's agent measures how fast its
|
||||
memory reads when it starts (it is in the agent's log as `memory_gbs`), and a
|
||||
token on a CPU reads every weight once: the estimate is that speed over the
|
||||
model's size — for a model of experts named like `30B-A3B`, its active part.
|
||||
On the test cluster it came within 15% of what llama.cpp measured. Several
|
||||
replies at once share the reads and each is slower.
|
||||
|
||||
**GPU tok/s** is the same on the cards, for the layout this cluster would
|
||||
give the model — the fewest cards that hold it, of each class, the fastest of
|
||||
those; its tooltip says which ("2× h100-80g, NVLink, vLLM", "2 nodes × 1×
|
||||
a100-80g over ib0 (InfiniBand 200 Gb/s), vLLM"). A card's memory bandwidth is
|
||||
known from its model name (an H100 SXM and an H100 PCIe differ by half); a card
|
||||
not in the table gets no figure. Tensor parallel across a node's cards divides
|
||||
what each reads, and adds two all-reduces a layer, short over NVLink and
|
||||
longer over PCIe; llama.cpp across cards splits by layer, so the cards take
|
||||
turns and a reply is no faster than on one; a pipeline across nodes takes turns
|
||||
too, and adds a hop over the fabric for each node. One card's figures were
|
||||
measured on the test cluster's RTX 6000; tensor parallel and the fabric are
|
||||
from what NCCL is known to take, not measured here.
|
||||
|
||||
1. Click a model to read its card.
|
||||
2. Pick one of its files, usually a `q4_k_m` for a first try, and click
|
||||
|
||||
@@ -37,6 +37,7 @@ rather than repeated.
|
||||
| `default_tpm` | `int` | [Auth](#auth) |
|
||||
| `signup_open` | `bool` | [Auth](#auth) |
|
||||
| `oidc` | `OIDC` | [Auth](#auth) |
|
||||
| `ldap` | `LDAP` | [Auth](#auth) |
|
||||
| `scim` | `SCIM` | [Auth](#auth) |
|
||||
| `max_key_ttl` | `time.Duration` | [Auth](#auth) |
|
||||
| `personal` | `Personal` | [Auth](#auth) |
|
||||
@@ -52,6 +53,7 @@ rather than repeated.
|
||||
| `retain_days` | `int` | [Batch](#batch) |
|
||||
| `max_bytes` | `int64` | [Cache](#cache) |
|
||||
| `max_entry_bytes` | `int64` | [Cache](#cache) |
|
||||
| `embedding_model` | `string` | [Cache](#cache) |
|
||||
| `model` | `string` | [Canary](#canary) |
|
||||
| `percent` | `int` | [Canary](#canary) |
|
||||
| `min_requests` | `int` | [Canary](#canary) |
|
||||
@@ -105,10 +107,13 @@ rather than repeated.
|
||||
| `url` | `string` | [External](#external) |
|
||||
| `model` | `string` | [External](#external) |
|
||||
| `api_key_file` | `string` | [External](#external) |
|
||||
| `api_key_files` | `[]ExternalKey` | [External](#external) |
|
||||
| `peer` | `string` | [External](#external) |
|
||||
| `api` | `string` | [External](#external) |
|
||||
| `workflow` | `string` | [External](#external) |
|
||||
| `output` | `string` | [External](#external) |
|
||||
| `file` | `string` | [ExternalKey](#externalkey) |
|
||||
| `weight` | `float64` | [ExternalKey](#externalkey) |
|
||||
| `budgets` | `[]FacilityBudget` | [Facility](#facility) |
|
||||
| `margin` | `float64` | [Facility](#facility) |
|
||||
| `gpu_watts` | `float64` | [Facility](#facility) |
|
||||
@@ -144,6 +149,21 @@ rather than repeated.
|
||||
| `tokenize_timeout` | `time.Duration` | [KVIndex](#kvindex) |
|
||||
| `min_blocks` | `int` | [KVIndex](#kvindex) |
|
||||
| `max_blocks` | `int` | [KVIndex](#kvindex) |
|
||||
| `urls` | `[]string` | [LDAP](#ldap) |
|
||||
| `start_tls` | `bool` | [LDAP](#ldap) |
|
||||
| `ca_file` | `string` | [LDAP](#ldap) |
|
||||
| `ca` | `string` | [LDAP](#ldap) |
|
||||
| `bind_dn` | `string` | [LDAP](#ldap) |
|
||||
| `bind_password_file` | `string` | [LDAP](#ldap) |
|
||||
| `base_dn` | `string` | [LDAP](#ldap) |
|
||||
| `group_base_dn` | `string` | [LDAP](#ldap) |
|
||||
| `user_filter` | `string` | [LDAP](#ldap) |
|
||||
| `label` | `string` | [LDAP](#ldap) |
|
||||
| `create_accounts` | `bool` | [LDAP](#ldap) |
|
||||
| `roles` | `map[string][]string` | [LDAP](#ldap) |
|
||||
| `memberships` | `map[string]string` | [LDAP](#ldap) |
|
||||
| `sync_interval` | `time.Duration` | [LDAP](#ldap) |
|
||||
| `timeout` | `time.Duration` | [LDAP](#ldap) |
|
||||
| `contact` | `string` | [Landing](#landing) |
|
||||
| `notice` | `string` | [Landing](#landing) |
|
||||
| `language` | `string` | [Landing](#landing) |
|
||||
@@ -160,6 +180,7 @@ rather than repeated.
|
||||
| `parallel` | `int` | [LlamaCppConfig](#llamacppconfig) |
|
||||
| `threads` | `int` | [LlamaCppConfig](#llamacppconfig) |
|
||||
| `api` | `string` | [Mirror](#mirror) |
|
||||
| `peers` | `[]string` | [Mirror](#mirror) |
|
||||
| `tls` | `tlsconf.Config` | [Mirror](#mirror) |
|
||||
| `registry` | `string` | [Mirror](#mirror) |
|
||||
| `timeout` | `time.Duration` | [Mirror](#mirror) |
|
||||
@@ -168,6 +189,7 @@ rather than repeated.
|
||||
| `aliases` | `[]string` | [Model](#model) |
|
||||
| `description` | `string` | [Model](#model) |
|
||||
| `source` | `string` | [Model](#model) |
|
||||
| `allow_pickle` | `bool` | [Model](#model) |
|
||||
| `profiles` | `[]Profile` | [Model](#model) |
|
||||
| `scaling` | `Scaling` | [Model](#model) |
|
||||
| `class` | `string` | [Model](#model) |
|
||||
@@ -238,6 +260,7 @@ rather than repeated.
|
||||
| `require_capabilities` | `bool` | [Policy](#policy) |
|
||||
| `validate_structured` | `bool` | [Policy](#policy) |
|
||||
| `judge` | `*JudgePolicy` | [Policy](#policy) |
|
||||
| `services` | `[]Service` | [Policy](#policy) |
|
||||
| `stream` | `string` | [Policy](#policy) |
|
||||
| `enabled` | `bool` | [Prefix](#prefix) |
|
||||
| `max_nodes` | `int` | [Prefix](#prefix) |
|
||||
@@ -301,6 +324,7 @@ rather than repeated.
|
||||
| `injection` | `string` | [Rules](#rules) |
|
||||
| `deny` | `[]Pattern` | [Rules](#rules) |
|
||||
| `max_chars` | `int` | [Rules](#rules) |
|
||||
| `restore` | `bool` | [Rules](#rules) |
|
||||
| `token_file` | `string` | [SCIM](#scim) |
|
||||
| `python` | `string` | [SGLangConfig](#sglangconfig) |
|
||||
| `ttft_p99` | `time.Duration` | [SLO](#slo) |
|
||||
@@ -361,8 +385,20 @@ rather than repeated.
|
||||
| `facility` | `Facility` | [Server](#server) |
|
||||
| `federation` | `Federation` | [Server](#server) |
|
||||
| `endpoints` | `[]Endpoint` | [Server](#server) |
|
||||
| `name` | `string` | [Service](#service) |
|
||||
| `url` | `string` | [Service](#service) |
|
||||
| `header_files` | `map[string]string` | [Service](#service) |
|
||||
| `body` | `string` | [Service](#service) |
|
||||
| `blocked` | `string` | [Service](#service) |
|
||||
| `reason` | `string` | [Service](#service) |
|
||||
| `on` | `string` | [Service](#service) |
|
||||
| `action` | `string` | [Service](#service) |
|
||||
| `timeout` | `time.Duration` | [Service](#service) |
|
||||
| `stopped` | `bool` | [ServingSettings](#servingsettings) |
|
||||
| `bin` | `string` | [SimConfig](#simconfig) |
|
||||
| `dir` | `string` | [StrataConfig](#strataconfig) |
|
||||
| `python` | `string` | [StrataConfig](#strataconfig) |
|
||||
| `engine` | `string` | [StrataConfig](#strataconfig) |
|
||||
| `python` | `string` | [TRTLLMConfig](#trtllmconfig) |
|
||||
| `store` | `string` | [Threads](#threads) |
|
||||
| `retain_hours` | `int` | [Threads](#threads) |
|
||||
@@ -394,6 +430,7 @@ rather than repeated.
|
||||
| `secret_file` | `string` | [Webhook](#webhook) |
|
||||
| `prompt_per_1k` | `int64` | [accounting.Price](#accountingprice) |
|
||||
| `completion_per_1k` | `int64` | [accounting.Price](#accountingprice) |
|
||||
| `cached_per_1k` | `int64` | [accounting.Price](#accountingprice) |
|
||||
| `per_image` | `int64` | [accounting.Price](#accountingprice) |
|
||||
| `per_mesh` | `int64` | [accounting.Price](#accountingprice) |
|
||||
| `name` | `string` | [admission.Band](#admissionband) |
|
||||
@@ -432,6 +469,7 @@ rather than repeated.
|
||||
| `queue_ttl` | `time.Duration` | [config.Band](#configband) |
|
||||
| `prompt_per_1k` | `int64` | [config.Price](#configprice) |
|
||||
| `completion_per_1k` | `int64` | [config.Price](#configprice) |
|
||||
| `cached_per_1k` | `int64` | [config.Price](#configprice) |
|
||||
| `per_image` | `int64` | [config.Price](#configprice) |
|
||||
| `per_mesh` | `int64` | [config.Price](#configprice) |
|
||||
| `disabled` | `[]string` | [engines.Config](#enginesconfig) |
|
||||
@@ -441,6 +479,7 @@ rather than repeated.
|
||||
| `trtllm` | `TRTLLMConfig` | [engines.Config](#enginesconfig) |
|
||||
| `sim` | `SimConfig` | [engines.Config](#enginesconfig) |
|
||||
| `comfyui` | `ComfyUIConfig` | [engines.Config](#enginesconfig) |
|
||||
| `strata` | `StrataConfig` | [engines.Config](#enginesconfig) |
|
||||
| `enabled` | `*bool` | [guard.Config](#guardconfig) |
|
||||
| `default` | `[]string` | [guard.Config](#guardconfig) |
|
||||
| `policies` | `map[string]Policy` | [guard.Config](#guardconfig) |
|
||||
@@ -616,6 +655,15 @@ OIDC is sign-in through an identity provider.
|
||||
|
||||
Takes a [OIDC](#oidc) block.
|
||||
|
||||
### `ldap`
|
||||
|
||||
`LDAP`
|
||||
|
||||
LDAP is sign-in against a directory, Active Directory above all, and
|
||||
the accounts kept in step with it.
|
||||
|
||||
Takes a [LDAP](#ldap) block.
|
||||
|
||||
### `scim`
|
||||
|
||||
`SCIM`
|
||||
@@ -728,6 +776,15 @@ Past it, the answers used longest ago go first.
|
||||
MaxEntryBytes bounds one answer; a longer one is not kept. 1 MB
|
||||
unless set.
|
||||
|
||||
### `embedding_model`
|
||||
|
||||
`string`
|
||||
|
||||
EmbeddingModel embeds the questions of projects that reuse answers to
|
||||
similar ones (llmgw keys cache --similar): an embedding model in the
|
||||
catalog. Unset, those projects reuse only the same request's answer.
|
||||
The gateway asks it on its own account, as it asks a judge.
|
||||
|
||||
## Canary
|
||||
|
||||
### `model`
|
||||
@@ -1103,6 +1160,15 @@ APIKeyFile holds the provider's key, on the control plane. A file
|
||||
rather than the key itself, so the key is in neither the catalog, the
|
||||
store nor the audit log; empty for a provider that asks for none.
|
||||
|
||||
### `api_key_files`
|
||||
|
||||
`[]ExternalKey`
|
||||
|
||||
APIKeyFiles are several keys for the provider, in place of
|
||||
APIKeyFile: one endpoint each (external:MODEL#1, #2…), so a key that
|
||||
answers 429 or 401 is passed over while its breaker is open and the
|
||||
request goes to another. A provider's limits are per key.
|
||||
|
||||
### `peer`
|
||||
|
||||
`string`
|
||||
@@ -1135,6 +1201,20 @@ plane.
|
||||
|
||||
Output is what it makes: pictures, or meshes (see ComfyUI.Output).
|
||||
|
||||
## ExternalKey
|
||||
|
||||
### `file`
|
||||
|
||||
`string`
|
||||
|
||||
File holds the key, on the control plane, as APIKeyFile does.
|
||||
|
||||
### `weight`
|
||||
|
||||
`float64`
|
||||
|
||||
Weight is its share of the requests against the others'; zero is 1.
|
||||
|
||||
## Facility
|
||||
|
||||
### `budgets`
|
||||
@@ -1401,6 +1481,114 @@ MaxBlocks bounds how many of a prompt's blocks are hashed and looked
|
||||
up. Beyond it a longer match still helps, but the difference no longer
|
||||
decides anything. Zero takes 4096.
|
||||
|
||||
## LDAP
|
||||
|
||||
### `urls`
|
||||
|
||||
`[]string`
|
||||
|
||||
URLs are the directory's servers, tried in turn: ldaps:// (636), or
|
||||
ldap:// (389) with StartTLS.
|
||||
|
||||
### `start_tls`
|
||||
|
||||
`bool`
|
||||
|
||||
StartTLS upgrades an ldap:// connection before anything is sent. A
|
||||
password never crosses a connection without TLS: ldap:// without it
|
||||
is refused.
|
||||
|
||||
### `ca_file`
|
||||
|
||||
`string`
|
||||
|
||||
CAFile is the authority the servers' certificates are checked against,
|
||||
when it is not the system's: an AD Certificate Services root.
|
||||
|
||||
### `ca`
|
||||
|
||||
`string`
|
||||
|
||||
CA is the same authority written out, PEM: kept in the cluster's
|
||||
settings, so no file has to be put on every control plane. Used in
|
||||
place of CAFile when both are set.
|
||||
|
||||
### `bind_dn`
|
||||
|
||||
`string`
|
||||
|
||||
BindDN and the file holding its password: a service account that may
|
||||
read users and groups. Nothing is written to the directory.
|
||||
|
||||
### `bind_password_file`
|
||||
|
||||
`string`
|
||||
|
||||
### `base_dn`
|
||||
|
||||
`string`
|
||||
|
||||
BaseDN is where people are searched for, subtree included.
|
||||
|
||||
### `group_base_dn`
|
||||
|
||||
`string`
|
||||
|
||||
GroupBaseDN is where their groups are; empty is BaseDN's domain root.
|
||||
|
||||
### `user_filter`
|
||||
|
||||
`string`
|
||||
|
||||
UserFilter narrows who may sign in, ANDed with the login match: a
|
||||
group (memberOf=...) or an OU's people only. Empty is every enabled
|
||||
user object.
|
||||
|
||||
### `label`
|
||||
|
||||
`string`
|
||||
|
||||
Label is what the form says to sign in with: "Sign in with your
|
||||
<label> account". Empty is "Active Directory".
|
||||
|
||||
### `create_accounts`
|
||||
|
||||
`bool`
|
||||
|
||||
CreateAccounts makes an account, active, for a person the directory
|
||||
vouches for and the gateway does not know. Off, only existing accounts
|
||||
sign in this way.
|
||||
|
||||
### `roles`
|
||||
|
||||
`map[string][]string`
|
||||
|
||||
Roles maps a staff role (viewer, operator, billing, admin) to the
|
||||
groups that give it, by name (CN) or whole DN. Set at every sign-in
|
||||
and sync: someone taken out of the group loses the role.
|
||||
|
||||
### `memberships`
|
||||
|
||||
`map[string]string`
|
||||
|
||||
Memberships maps a group, by CN or DN, to a place in the tree —
|
||||
org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT — joined at sign-in
|
||||
and kept by the sync, which also takes away a place whose group the
|
||||
person has left.
|
||||
|
||||
### `sync_interval`
|
||||
|
||||
`time.Duration`
|
||||
|
||||
SyncInterval is how often the leader reads the directory. Zero is
|
||||
15m; negative turns the sync off, leaving only sign-in.
|
||||
|
||||
### `timeout`
|
||||
|
||||
`time.Duration`
|
||||
|
||||
Timeout bounds each round trip to a server. Zero is 10s.
|
||||
|
||||
## Landing
|
||||
|
||||
### `contact`
|
||||
@@ -1539,6 +1727,14 @@ The management port, not the one nodes fetch bytes from. Those are two
|
||||
listeners on the mirror precisely so this one can be locked down without
|
||||
re-certifying every agent's fetcher.
|
||||
|
||||
### `peers`
|
||||
|
||||
`[]string`
|
||||
|
||||
Peers are the management addresses of mirrors kept in step with this
|
||||
one (llmgw mirror serve --peer): asked when it cannot be reached, and
|
||||
a node given the same file on one of them while it is down.
|
||||
|
||||
### `tls`
|
||||
|
||||
`tlsconf.Config`
|
||||
@@ -1608,6 +1804,16 @@ choice by guesswork.
|
||||
|
||||
Source is the weights path or a HuggingFace id.
|
||||
|
||||
### `allow_pickle`
|
||||
|
||||
`bool`
|
||||
|
||||
AllowPickle lets the nodes fetch this model's weights, or its
|
||||
adapters', in a pickle format (.bin, .pt, .pth, .ckpt), which run code
|
||||
as they are loaded. Off, such a source is refused when the model is
|
||||
added and by the node before it fetches anything. For a model that has
|
||||
no safetensors or GGUF form and whose files were checked.
|
||||
|
||||
### `profiles`
|
||||
|
||||
`[]Profile`
|
||||
@@ -2136,6 +2342,12 @@ Judge has a judge model on the cluster read the text too.
|
||||
|
||||
Takes a [JudgePolicy](#judgepolicy) block.
|
||||
|
||||
### `services`
|
||||
|
||||
`[]Service`
|
||||
|
||||
Services are outside guardrails asked over HTTP, beside the judge.
|
||||
|
||||
### `stream`
|
||||
|
||||
`string`
|
||||
@@ -2610,6 +2822,14 @@ Deny are patterns, matched against the text as a model reads it.
|
||||
|
||||
MaxChars refuses text longer than this; zero is no limit.
|
||||
|
||||
### `restore`
|
||||
|
||||
`bool`
|
||||
|
||||
Restore, on input, puts what redact rewrote as placeholders back into
|
||||
the answer: the model reads "[EMAIL_1]", the client reads its own
|
||||
address. Only the values that request sent, only into its answer.
|
||||
|
||||
## SCIM
|
||||
|
||||
### `token_file`
|
||||
@@ -3117,6 +3337,67 @@ Endpoints is the phase-1 static replica list. It is only consulted when
|
||||
no model declares a profile, which is what makes a router-only smoke test
|
||||
against sim-engines possible without a control plane.
|
||||
|
||||
## Service
|
||||
|
||||
### `name`
|
||||
|
||||
`string`
|
||||
|
||||
Name says which service decided, in the verdict's rule.
|
||||
|
||||
### `url`
|
||||
|
||||
`string`
|
||||
|
||||
URL is POSTed the body.
|
||||
|
||||
### `header_files`
|
||||
|
||||
`map[string]string`
|
||||
|
||||
HeaderFiles are headers whose values are read from files each time,
|
||||
so a rotated key needs no restart: {Authorization: /etc/…/lakera}.
|
||||
|
||||
### `body`
|
||||
|
||||
`string`
|
||||
|
||||
Body is the request, a template given .Text; {{json .Text}} writes it
|
||||
as a JSON string. Empty is {"input": <text>}.
|
||||
|
||||
### `blocked`
|
||||
|
||||
`string`
|
||||
|
||||
Blocked is where in the answer the service says the text is unsafe:
|
||||
a dotted path, numbers for list items (results.0.flagged). True, a
|
||||
number above zero, a non-empty list or string other than "false" is
|
||||
unsafe.
|
||||
|
||||
### `reason`
|
||||
|
||||
`string`
|
||||
|
||||
Reason is where it says why, for the verdict; optional.
|
||||
|
||||
### `on`
|
||||
|
||||
`string`
|
||||
|
||||
On: input, output or both. Empty is input.
|
||||
|
||||
### `action`
|
||||
|
||||
`string`
|
||||
|
||||
Action on unsafe: block or flag. Empty is block.
|
||||
|
||||
### `timeout`
|
||||
|
||||
`time.Duration`
|
||||
|
||||
Timeout bounds one call. Empty is 3s; past it the policy's fail says.
|
||||
|
||||
## ServingSettings
|
||||
|
||||
### `stopped`
|
||||
@@ -3138,6 +3419,27 @@ entries ask. In force on every control plane within seconds.
|
||||
Bin is the llmgw binary itself; the engine is one of its subcommands.
|
||||
Empty means whatever started this agent, which is right almost always.
|
||||
|
||||
## StrataConfig
|
||||
|
||||
### `dir`
|
||||
|
||||
`string`
|
||||
|
||||
Dir is Strata's source directory, where setup.py and serve/ are.
|
||||
|
||||
### `python`
|
||||
|
||||
`string`
|
||||
|
||||
Python is the interpreter of its environment; Dir's .venv when unset.
|
||||
|
||||
### `engine`
|
||||
|
||||
`string`
|
||||
|
||||
Engine is the directory of its prebuilt engine, where the strata
|
||||
binary and the CUDA libraries it ships are; Dir's engine/ when unset.
|
||||
|
||||
## TRTLLMConfig
|
||||
|
||||
### `python`
|
||||
@@ -3355,6 +3657,15 @@ is sha256=<hex HMAC of the body>.
|
||||
|
||||
`int64`
|
||||
|
||||
### `cached_per_1k`
|
||||
|
||||
`int64`
|
||||
|
||||
CachedPer1k is what a prompt token costs that the engine or provider
|
||||
read from its cache instead of computing: the part of prompt_tokens
|
||||
the answer counts as cached_tokens. Zero charges those at
|
||||
PromptPer1k, as before there was a rate for them.
|
||||
|
||||
### `per_image`
|
||||
|
||||
`int64`
|
||||
@@ -3629,6 +3940,14 @@ had rather wait behind people than be turned away.
|
||||
|
||||
`int64`
|
||||
|
||||
### `cached_per_1k`
|
||||
|
||||
`int64`
|
||||
|
||||
CachedPer1k is the cost of a prompt token the engine or provider read
|
||||
from its cache (the answer's cached_tokens, counted within the
|
||||
prompt's). Zero charges them at prompt_per_1k.
|
||||
|
||||
### `per_image`
|
||||
|
||||
`int64`
|
||||
@@ -3693,6 +4012,15 @@ ComfyUI makes pictures; offered by a node whose ComfyUI is named here.
|
||||
|
||||
Takes a [ComfyUIConfig](#comfyuiconfig) block.
|
||||
|
||||
### `strata`
|
||||
|
||||
`StrataConfig`
|
||||
|
||||
Strata serves a few mixture-of-experts models from RAM and one card;
|
||||
offered by a node whose Strata is named here, or that installed it.
|
||||
|
||||
Takes a [StrataConfig](#strataconfig) block.
|
||||
|
||||
## guard.Config
|
||||
|
||||
### `enabled`
|
||||
|
||||
@@ -12,6 +12,7 @@ require (
|
||||
github.com/charmbracelet/x/ansi v0.11.6
|
||||
github.com/coreos/go-oidc/v3 v3.21.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/go-ldap/ldap/v3 v3.4.15
|
||||
github.com/go-zeromq/zmq4 v0.17.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
github.com/knadh/koanf/parsers/yaml v1.1.1
|
||||
@@ -45,6 +46,7 @@ require (
|
||||
require (
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
filippo.io/hpke v0.4.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
@@ -57,6 +59,7 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
|
||||
@@ -8,8 +8,12 @@ filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
|
||||
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/NVIDIA/go-nvml v0.13.4-0 h1:o3jp9u2x1R9ShFE3v+Aesp55XOSIQFMJz/VGNUcJaNE=
|
||||
github.com/NVIDIA/go-nvml v0.13.4-0/go.mod h1:id63qwpoDWpFXwnwM6psDCSqW4BmNu6mWpr4YeQtPGo=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -46,8 +50,12 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8 h1:H9AZkK22UOmfX8J84ubyaZxKJZ3FMHVwn8swoMML7iQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15 h1:eRB4f4Ecn1POL1mO86+uUDlecmyWsdiJwh60bAUS4UY=
|
||||
github.com/go-ldap/ldap/v3 v3.4.15/go.mod h1:jeD0c98EdZQCKKKKe4kTWs5aCf6y+j8KuYsYLznZSQk=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -69,6 +77,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
@@ -81,6 +91,18 @@ github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk=
|
||||
github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package account
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
type fakeDirectory struct {
|
||||
asked []string
|
||||
u store.User
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDirectory) SignIn(_ context.Context, login, _ string) (store.User, error) {
|
||||
f.asked = append(f.asked, login)
|
||||
return f.u, f.err
|
||||
}
|
||||
|
||||
// With a directory, a name the store has no password for is the
|
||||
// directory's to check; an account with a password here — the
|
||||
// administrator init made — is still checked here, so a directory that is
|
||||
// down locks nobody out.
|
||||
func TestPasswordsKeptElsewhereAreTheDirectorysToCheck(t *testing.T) {
|
||||
svc, st := newService(t)
|
||||
ctx := t.Context()
|
||||
hash, _ := HashPassword("local-secret")
|
||||
admin := store.User{ID: "usr-admin", Email: "admin@example.com", Name: "a", PasswordHash: hash,
|
||||
State: store.UserActive, CreatedAt: time.Now()}
|
||||
ada := store.User{ID: "usr-ada", Email: "ada@corp.example", Name: "Ada", PasswordHash: "!ldap",
|
||||
State: store.UserActive, CreatedAt: time.Now()}
|
||||
for _, u := range []store.User{admin, ada} {
|
||||
if err := st.CreateUser(ctx, u); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
dir := &fakeDirectory{u: ada}
|
||||
svc.SetDirectory(dir)
|
||||
|
||||
if _, _, err := svc.SignIn(ctx, "admin@example.com", "local-secret"); err != nil || len(dir.asked) != 0 {
|
||||
t.Fatalf("the local administrator: %v, directory asked %v", err, dir.asked)
|
||||
}
|
||||
// A name, not an address, and an account kept by the directory.
|
||||
for _, login := range []string{`CORP\ada`, "ada@corp.example"} {
|
||||
if tok, _, err := svc.SignIn(ctx, login, "ad-secret"); err != nil || tok == "" {
|
||||
t.Errorf("%s: %v", login, err)
|
||||
}
|
||||
}
|
||||
if len(dir.asked) != 2 || dir.asked[0] != `CORP\ada` {
|
||||
t.Errorf("the directory was asked %v", dir.asked)
|
||||
}
|
||||
dir.err = ErrBadCredentials
|
||||
if _, _, err := svc.SignIn(ctx, "ada", "wrong"); !errors.Is(err, ErrBadCredentials) {
|
||||
t.Errorf("a password the directory refused: %v", err)
|
||||
}
|
||||
// A reset link would give a directory's account a password here.
|
||||
if _, _, err := svc.RequestReset(ctx, st, "ada@corp.example"); !errors.Is(err, ErrNoReset) {
|
||||
t.Errorf("a directory's account was sent a reset link: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -80,8 +80,26 @@ type Service struct {
|
||||
|
||||
mu sync.Mutex
|
||||
attempts map[string]*attemptWindow
|
||||
|
||||
// dir checks the passwords of accounts this store keeps none for.
|
||||
dir Directory
|
||||
}
|
||||
|
||||
// Directory checks a password against an outside directory — Active
|
||||
// Directory over LDAP — and answers the account to sign in: made or kept in
|
||||
// step with the directory. Its errors are this package's ErrBadCredentials
|
||||
// and ErrSuspended for the person's own, and anything else for the
|
||||
// directory's.
|
||||
type Directory interface {
|
||||
SignIn(ctx context.Context, login, password string) (store.User, error)
|
||||
}
|
||||
|
||||
// SetDirectory has passwords for accounts with none here, and names that are
|
||||
// not addresses, checked by the directory. An account with a password here
|
||||
// — the administrator init made — is still checked here, so a directory that
|
||||
// is down locks nobody out.
|
||||
func (s *Service) SetDirectory(d Directory) { s.dir = d }
|
||||
|
||||
type attemptWindow struct {
|
||||
count int
|
||||
since time.Time
|
||||
@@ -137,6 +155,7 @@ func (s *Service) SignUp(ctx context.Context, email, name, password string) erro
|
||||
// SignIn checks a password and opens a session, returning the session token and
|
||||
// the CSRF token that must accompany the forms in it.
|
||||
func (s *Service) SignIn(ctx context.Context, email, password string) (token, csrf string, err error) {
|
||||
login := strings.TrimSpace(email)
|
||||
email = store.NormalizeEmail(email)
|
||||
// Counted per address and the client trying it, so somebody guessing
|
||||
// from elsewhere cannot lock the owner out of their own account; and per
|
||||
@@ -151,6 +170,15 @@ func (s *Service) SignIn(ctx context.Context, email, password string) (token, cs
|
||||
}
|
||||
|
||||
u, err := s.store.UserByEmail(ctx, email)
|
||||
// No password kept here — the directory's, or nobody's — and a
|
||||
// directory to ask: it decides.
|
||||
if s.dir != nil && (errors.Is(err, store.ErrNotFound) || (err == nil && strings.HasPrefix(u.PasswordHash, "!"))) {
|
||||
if u, err = s.dir.SignIn(ctx, login, password); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
s.clearAttempts(byClient)
|
||||
return s.open(ctx, u)
|
||||
}
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
// Verify anyway, against a hash that cannot match. Returning early here
|
||||
// would let a stopwatch enumerate every account on the system.
|
||||
@@ -180,7 +208,11 @@ func (s *Service) SignIn(ctx context.Context, email, password string) (token, cs
|
||||
case store.UserSuspended:
|
||||
return "", "", ErrSuspended
|
||||
}
|
||||
return s.open(ctx, u)
|
||||
}
|
||||
|
||||
// open makes a session for a person whose password was right.
|
||||
func (s *Service) open(ctx context.Context, u store.User) (token, csrf string, err error) {
|
||||
if token, err = store.NewSessionToken(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -402,7 +434,10 @@ func (s *Service) RequestReset(ctx context.Context, rs ResetStore, email string)
|
||||
return "", store.User{}, ErrTooManyTries
|
||||
}
|
||||
u, err := rs.UserByEmail(ctx, email)
|
||||
if err != nil || u.PasswordHash == "" || u.State != store.UserActive {
|
||||
// An account whose password is kept elsewhere — "!oidc", "!scim",
|
||||
// "!ldap" — gets no link: setting one here would give it a second way
|
||||
// in that its directory knows nothing of, and outlives its leaving.
|
||||
if err != nil || u.PasswordHash == "" || strings.HasPrefix(u.PasswordHash, "!") || u.State != store.UserActive {
|
||||
return "", store.User{}, ErrNoReset
|
||||
}
|
||||
token, err := rs.CreatePasswordReset(ctx, u.ID, ResetLinkTTL)
|
||||
|
||||
@@ -323,7 +323,7 @@ func (a *Accountant) Record(res Reservation, u store.Usage) {
|
||||
if u.ServedModel != "" && u.ServedModel != u.Model && a.pricing != nil {
|
||||
price = a.pricing.For(u.ServedModel)
|
||||
}
|
||||
u.CostMicros = price.Cost(u.PromptTokens, u.CompletionTokens, u.Images, u.Meshes)
|
||||
u.CostMicros = price.Cost(u.PromptTokens, u.CachedTokens, u.CompletionTokens, u.Images, u.Meshes)
|
||||
for _, b := range res.Budgets {
|
||||
a.budgets.Add(b.Counter, b.Period, u.CostMicros)
|
||||
}
|
||||
|
||||
@@ -39,20 +39,37 @@ func TestCostRoundsUpSoSmallRequestsAreNotFree(t *testing.T) {
|
||||
// Rounding down would make a stream of tiny requests cost nothing, which is
|
||||
// exactly the traffic shape a quota exists to bound.
|
||||
p := Price{PromptPer1k: 1000, CompletionPer1k: 3000}
|
||||
if got := p.Cost(1, 0, 0, 0); got != 1 {
|
||||
if got := p.Cost(1, 0, 0, 0, 0); got != 1 {
|
||||
t.Errorf("one prompt token cost %d", got)
|
||||
}
|
||||
if got := p.Cost(1000, 1000, 0, 0); got != 4000 {
|
||||
if got := p.Cost(1000, 0, 1000, 0, 0); got != 4000 {
|
||||
t.Errorf("a thousand of each cost %d, want 4000", got)
|
||||
}
|
||||
if got := p.Cost(0, 0, 0, 0); got != 0 {
|
||||
if got := p.Cost(0, 0, 0, 0, 0); got != 0 {
|
||||
t.Errorf("an empty request cost %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACachedPromptCostsTheCachedRate(t *testing.T) {
|
||||
p := Price{PromptPer1k: 1000, CachedPer1k: 100, CompletionPer1k: 3000}
|
||||
// 10,000 prompt tokens of which 9,000 were read from the cache: 1,000 at
|
||||
// the prompt rate and 9,000 at the cached one.
|
||||
if got, want := p.Cost(10000, 9000, 0, 0, 0), int64(1000+900); got != want {
|
||||
t.Errorf("a 90%% cached prompt cost %d, want %d", got, want)
|
||||
}
|
||||
// More cached than prompt is an engine's miscount, not a refund.
|
||||
if got := p.Cost(100, 500, 0, 0, 0); got != 10 {
|
||||
t.Errorf("cached beyond the prompt cost %d, want 10", got)
|
||||
}
|
||||
// No cached rate: cached tokens cost what any prompt token does.
|
||||
if got := (Price{PromptPer1k: 1000}).Cost(10000, 9000, 0, 0, 0); got != 10000 {
|
||||
t.Errorf("without a cached rate a cached prompt cost %d, want 10000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPictureCostsItsPriceOnTopOfItsPrompt(t *testing.T) {
|
||||
p := Price{PromptPer1k: 1000, PerImage: 50000}
|
||||
if got := p.Cost(20, 0, 3, 0); got != 20+150000 {
|
||||
if got := p.Cost(20, 0, 0, 3, 0); got != 20+150000 {
|
||||
t.Errorf("three pictures from a 20-token prompt cost %d", got)
|
||||
}
|
||||
if (Price{PerImage: 1}).Zero() {
|
||||
|
||||
@@ -18,6 +18,11 @@ import (
|
||||
type Price struct {
|
||||
PromptPer1k int64 `koanf:"prompt_per_1k"`
|
||||
CompletionPer1k int64 `koanf:"completion_per_1k"`
|
||||
// CachedPer1k is what a prompt token costs that the engine or provider
|
||||
// read from its cache instead of computing: the part of prompt_tokens
|
||||
// the answer counts as cached_tokens. Zero charges those at
|
||||
// PromptPer1k, as before there was a rate for them.
|
||||
CachedPer1k int64 `koanf:"cached_per_1k"`
|
||||
// PerImage is what one picture costs, for a model that makes them: a
|
||||
// picture's cost is the card's time, which its prompt's few tokens do
|
||||
// not measure.
|
||||
@@ -33,15 +38,24 @@ type Price struct {
|
||||
// whose budget is spent, which is what makes the plan's budget fallback work
|
||||
// (§7.7).
|
||||
func (p Price) Zero() bool {
|
||||
return p.PromptPer1k == 0 && p.CompletionPer1k == 0 && p.PerImage == 0 && p.PerMesh == 0
|
||||
return p.PromptPer1k == 0 && p.CompletionPer1k == 0 && p.CachedPer1k == 0 && p.PerImage == 0 && p.PerMesh == 0
|
||||
}
|
||||
|
||||
// Cost computes what a request spent, rounding up.
|
||||
//
|
||||
// Up rather than to nearest: rounding down would make a stream of tiny requests
|
||||
// free, which is exactly the shape of traffic a quota exists to bound.
|
||||
func (p Price) Cost(promptTokens, completionTokens, images, meshes int64) int64 {
|
||||
return divUp(promptTokens*p.PromptPer1k, 1000) + divUp(completionTokens*p.CompletionPer1k, 1000) +
|
||||
//
|
||||
// cachedTokens are counted within promptTokens, as OpenAI's usage counts
|
||||
// them, and are charged at the cached rate instead of the prompt rate.
|
||||
func (p Price) Cost(promptTokens, cachedTokens, completionTokens, images, meshes int64) int64 {
|
||||
cachedTokens = min(max(cachedTokens, 0), max(promptTokens, 0))
|
||||
cachedRate := p.CachedPer1k
|
||||
if cachedRate == 0 {
|
||||
cachedRate = p.PromptPer1k
|
||||
}
|
||||
return divUp((promptTokens-cachedTokens)*p.PromptPer1k+cachedTokens*cachedRate, 1000) +
|
||||
divUp(completionTokens*p.CompletionPer1k, 1000) +
|
||||
max(images, 0)*p.PerImage + max(meshes, 0)*p.PerMesh
|
||||
}
|
||||
|
||||
|
||||
+59
-8
@@ -12,6 +12,7 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
@@ -392,8 +393,11 @@ func (a *Agent) settleAdopted(sh *supervisor.Shard, o supervisor.Orphan) {
|
||||
_ = a.sup.Stop(ctx, o.ShardID)
|
||||
return
|
||||
}
|
||||
// What it computes on, for one started before the agent kept that.
|
||||
if a.sup.ComputeOf(o.ShardID) == nil {
|
||||
// What it computes on, for one started before the agent kept that — or
|
||||
// kept as the CPU for a GPU placement by an agent that did not follow
|
||||
// an engine into a process group of its own, which the check above has
|
||||
// just found on its GPUs.
|
||||
if c := a.sup.ComputeOf(o.ShardID); c == nil || (len(o.Spec.GPUIndices) > 0 && c.Backend == "CPU") {
|
||||
if c, ok := a.gpuCompute(ctx, o.Spec.GPUIndices, sh.Status().PID); ok {
|
||||
a.sup.SetCompute(o.ShardID, c)
|
||||
}
|
||||
@@ -513,6 +517,7 @@ func (a *Agent) scan(ctx context.Context) error {
|
||||
inv, err := discovery.Discover(ctx, opts, a.log)
|
||||
host := discovery.DiscoverHost()
|
||||
host.MinComputeCapability = discovery.MinComputeCapability(inv.GPUs)
|
||||
host.MemBandwidthGBs = a.memBandwidth()
|
||||
a.mu.Lock()
|
||||
a.inv = inv
|
||||
a.prov.Host = host
|
||||
@@ -569,6 +574,9 @@ func (a *Agent) session(ctx context.Context, server string) error {
|
||||
reg.Host = &llmgwv1.Host{
|
||||
OsId: h.OSID, OsVersion: h.OSVersion, OsCodename: h.OSCodename,
|
||||
Arch: h.Arch, CpuFlags: h.CPUFlags, NvidiaDriver: h.NVIDIADriver,
|
||||
// What is measured by now, not what the startup scan found before
|
||||
// the measurement ended: a reconnect would otherwise say 0.
|
||||
MemBandwidthGbs: a.memBandwidth(),
|
||||
}
|
||||
|
||||
if err := stream.Send(&llmgwv1.AgentMessage{
|
||||
@@ -666,6 +674,7 @@ func (a *Agent) heartbeat() *llmgwv1.AgentMessage {
|
||||
// without anybody restarting anything.
|
||||
hb.MemoryTotalMb = int64(discovery.MemoryTotalMB())
|
||||
hb.CpuCount = int32(runtime.NumCPU())
|
||||
hb.MemBandwidthGbs = a.memBandwidth()
|
||||
|
||||
a.reprobeEngines()
|
||||
a.mu.Lock()
|
||||
@@ -1124,7 +1133,7 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
|
||||
},
|
||||
})
|
||||
})
|
||||
if path, err := a.weights().Ensure(ctx, spec.Profile.ModelPath); err != nil {
|
||||
if path, err := a.weights().Ensure(ctx, spec.Profile.ModelPath, spec.Profile.AllowPickle); err != nil {
|
||||
stop()
|
||||
report(supervisor.Failed, err.Error(), nil)
|
||||
return
|
||||
@@ -1145,7 +1154,7 @@ 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}})
|
||||
})
|
||||
path, err := a.weights().Ensure(ctx, m["source"])
|
||||
path, err := a.weights().Ensure(ctx, m["source"], m["allow_pickle"] == "true")
|
||||
stop()
|
||||
if err != nil {
|
||||
report(supervisor.Failed, fmt.Sprintf("model %s: %v", m["name"], err), nil)
|
||||
@@ -1161,7 +1170,7 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
|
||||
// loads them at start, and one missing is a replica that answers for the
|
||||
// base model under the adapter's name.
|
||||
for i, ad := range spec.Profile.Adapters {
|
||||
path, err := a.weights().Ensure(ctx, ad.Path)
|
||||
path, err := a.weights().Ensure(ctx, ad.Path, spec.Profile.AllowPickle)
|
||||
if err != nil {
|
||||
report(supervisor.Failed, fmt.Sprintf("adapter %s: %v", ad.Name, err), nil)
|
||||
return
|
||||
@@ -1505,18 +1514,25 @@ func ResolveManagement(c Config) (addr, iface string) {
|
||||
// shard computes on as soon as that is known: an adopted engine's is found
|
||||
// after registration, which carried what was known then.
|
||||
func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMessage) {
|
||||
sent := map[string]bool{}
|
||||
// What was sent for each shard, so a compute that changes is sent again:
|
||||
// an adopted shard's kept one is sent at once, and adopting it may then
|
||||
// find it was kept wrong.
|
||||
sent := map[string]string{}
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for range 60 {
|
||||
for _, st := range a.sup.Statuses() {
|
||||
if st.State.Terminal() || sent[st.ID] {
|
||||
if st.State.Terminal() {
|
||||
continue
|
||||
}
|
||||
c := a.sup.ComputeOf(st.ID)
|
||||
if c == nil {
|
||||
continue
|
||||
}
|
||||
said := fmt.Sprint(*c)
|
||||
if sent[st.ID] == said {
|
||||
continue
|
||||
}
|
||||
sh, err := a.sup.Get(st.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
@@ -1527,7 +1543,7 @@ func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMess
|
||||
}}}
|
||||
select {
|
||||
case out <- msg:
|
||||
sent[st.ID] = true
|
||||
sent[st.ID] = said
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
@@ -1539,3 +1555,38 @@ func (a *Agent) reportCompute(ctx context.Context, out chan<- *llmgwv1.AgentMess
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// memBandwidth is the node's memory read rate in GB/s, 0 until measured.
|
||||
// Measured in the background, the first time it is asked for: it reads half
|
||||
// a gigabyte, which must not hold up an agent reaching its control plane,
|
||||
// and the heartbeat carries it when it is known. Not at once but after a
|
||||
// minute or so, chosen at random, and again every hour, keeping the best:
|
||||
// VMs on one host that all restart together measure each other, and read a
|
||||
// third less than any of them gets alone. Once a process rather than an
|
||||
// agent, so tests running many agents in one read it once.
|
||||
func (a *Agent) memBandwidth() float64 {
|
||||
bwOnce.Do(func() {
|
||||
go func() {
|
||||
time.Sleep(bandwidthDelay())
|
||||
for {
|
||||
if bw := measureBandwidth(); bw > math.Float64frombits(bwBits.Load()) {
|
||||
bwBits.Store(math.Float64bits(bw))
|
||||
a.log.Info("this node's memory", "read_gbs", math.Round(bw*10)/10)
|
||||
}
|
||||
time.Sleep(time.Hour)
|
||||
}
|
||||
}()
|
||||
})
|
||||
return math.Float64frombits(bwBits.Load())
|
||||
}
|
||||
|
||||
// bandwidthDelay is how long an agent waits before measuring, so agents
|
||||
// started together measure apart.
|
||||
var bandwidthDelay = func() time.Duration { return 10*time.Second + rand.N(110*time.Second) }
|
||||
|
||||
var (
|
||||
bwOnce sync.Once
|
||||
bwBits atomic.Uint64
|
||||
// measureBandwidth is replaced in tests, which have no use for it.
|
||||
measureBandwidth = discovery.MeasureMemoryBandwidth
|
||||
)
|
||||
@@ -671,7 +671,7 @@ func TestAShardWithMissingWeightsFailsBeforeLaunching(t *testing.T) {
|
||||
// nobody is watching — so it is caught here, where the message can say
|
||||
// what is actually wrong.
|
||||
a := &Agent{cfg: Config{WeightsCache: t.TempDir()}, log: slog.New(slog.DiscardHandler)}
|
||||
_, err := a.weights().Ensure(t.Context(), filepath.Join(t.TempDir(), "not-there.gguf"))
|
||||
_, err := a.weights().Ensure(t.Context(), filepath.Join(t.TempDir(), "not-there.gguf"), false)
|
||||
if err == nil {
|
||||
t.Fatal("a missing weights file was accepted")
|
||||
}
|
||||
|
||||
@@ -52,6 +52,8 @@ type GPU struct {
|
||||
// memory the instance's share. Utilisation stays unreported, because no
|
||||
// driver measures it per instance.
|
||||
MIG *MIGDevice `json:"mig,omitempty"`
|
||||
// PCIe is how the card is linked to its host, zero when not read.
|
||||
PCIe PCIeLink `json:"pcie,omitempty"`
|
||||
}
|
||||
|
||||
// GPUHealth records conditions that make a GPU unsafe to schedule onto.
|
||||
|
||||
@@ -67,10 +67,23 @@ func DiscoverGPUsNVML(_ context.Context) ([]GPU, error) {
|
||||
if major, minor, ret := dev.GetCudaComputeCapability(); ret == nvml.SUCCESS {
|
||||
g.ComputeCapability = fmt.Sprintf("%d.%d", major, minor)
|
||||
}
|
||||
if v, ret := dev.GetMaxPcieLinkGeneration(); ret == nvml.SUCCESS {
|
||||
g.PCIe.Gen = v
|
||||
}
|
||||
if v, ret := dev.GetGpuMaxPcieLinkGeneration(); ret == nvml.SUCCESS {
|
||||
g.PCIe.GPUGen = v
|
||||
}
|
||||
if v, ret := dev.GetCurrPcieLinkWidth(); ret == nvml.SUCCESS {
|
||||
g.PCIe.Width = v
|
||||
}
|
||||
if v, ret := dev.GetMaxPcieLinkWidth(); ret == nvml.SUCCESS {
|
||||
g.PCIe.WidthMax = v
|
||||
}
|
||||
if pci, ret := dev.GetPciInfo(); ret == nvml.SUCCESS {
|
||||
g.PCIBusID = busIDString(pci.BusId[:])
|
||||
byBusID[normalizeBusID(g.PCIBusID)] = i
|
||||
}
|
||||
g.PCIe = withSysfs(g.PCIe, "/sys/bus/pci/devices", g.PCIBusID)
|
||||
if t, ret := dev.GetTemperature(nvml.TEMPERATURE_GPU); ret == nvml.SUCCESS {
|
||||
g.Health.TemperatureC = int(t)
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ func DiscoverGPUsSMI(ctx context.Context, binary string) ([]GPU, error) {
|
||||
if err != nil || len(cards) == 0 {
|
||||
return cards, err
|
||||
}
|
||||
readPCIe(ctx, binary, cards)
|
||||
// The query lists whole cards, MIG mode or not. A card in MIG mode is
|
||||
// replaced by its instances, which only -L names and only the full
|
||||
// report sizes; asked only when some card could be in MIG mode, since
|
||||
|
||||
@@ -31,6 +31,10 @@ type Host struct {
|
||||
// MinComputeCapability is the oldest GPU's, 0 when there are none or it
|
||||
// is not known. Not read here but from the GPU inventory, by the agent.
|
||||
MinComputeCapability float64 `json:"min_compute_capability,omitempty"`
|
||||
// MemBandwidthGBs is how fast the memory reads, in GB/s, 0 when not
|
||||
// measured: what decides a model's speed on the CPU. Not read here but
|
||||
// measured by the agent when it starts (MeasureMemoryBandwidth).
|
||||
MemBandwidthGBs float64 `json:"mem_bandwidth_gbs,omitempty"`
|
||||
}
|
||||
|
||||
// Family is the packaging family: "deb", "rpm", or "" when neither is known.
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// How fast this machine reads its memory, which on a CPU is what decides how
|
||||
// fast a model answers: every token of a reply reads all the weights it uses
|
||||
// once, so a node that reads 40 GB/s writes a 4 GB model's tokens about ten
|
||||
// times a second, whatever its core count. Measured once when the agent
|
||||
// starts, in a fraction of a second, by every CPU summing a share of a buffer
|
||||
// too large for any cache — a VM sharing its host's memory with others gets
|
||||
// its share, which is the number that matters for it.
|
||||
|
||||
// bandwidthBuffer is what is read at most; the least is eight times the
|
||||
// processor's last cache, and never under minBuffer. Smaller, it would be
|
||||
// the cache that was measured — some hold a gigabyte — and the figure kept
|
||||
// would be too high.
|
||||
const (
|
||||
bandwidthBuffer = 2 << 30
|
||||
minBuffer = 256 << 20
|
||||
)
|
||||
|
||||
// MeasureMemoryBandwidth is the memory's read rate in GB/s, the best of a
|
||||
// few passes; 0 when the machine has too little free memory to measure.
|
||||
func MeasureMemoryBandwidth() float64 {
|
||||
size := int64(bandwidthBuffer)
|
||||
// Never more than a quarter of what is free: a node already holding
|
||||
// models must not be pushed into swap by a measurement. Without room
|
||||
// for a buffer past the cache, none is measured: the hourly retry
|
||||
// measures it when there is.
|
||||
if avail := memAvailable("/proc/meminfo"); avail > 0 {
|
||||
size = min(size, avail/4)
|
||||
}
|
||||
if size < max(minBuffer, 8*lastCacheBytes("/sys/devices/system/cpu")) {
|
||||
return 0
|
||||
}
|
||||
buf := make([]uint64, size/8)
|
||||
for i := range buf {
|
||||
buf[i] = uint64(i)
|
||||
}
|
||||
workers := runtime.NumCPU()
|
||||
chunk := (len(buf) + workers - 1) / workers
|
||||
best := 0.0
|
||||
for pass := 0; pass < 4; pass++ {
|
||||
var wg sync.WaitGroup
|
||||
start := time.Now()
|
||||
for w := 0; w < workers; w++ {
|
||||
lo, hi := w*chunk, min((w+1)*chunk, len(buf))
|
||||
if lo >= hi {
|
||||
continue
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(part []uint64) {
|
||||
defer wg.Done()
|
||||
sink(sum(part))
|
||||
}(buf[lo:hi])
|
||||
}
|
||||
wg.Wait()
|
||||
if s := time.Since(start).Seconds(); s > 0 {
|
||||
best = max(best, float64(size)/s/1e9)
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// sum reads every word, eight at a time so the loop is not what limits it.
|
||||
func sum(p []uint64) uint64 {
|
||||
var a, b, c, d, e, f, g, h uint64
|
||||
i := 0
|
||||
for ; i+8 <= len(p); i += 8 {
|
||||
a += p[i]
|
||||
b += p[i+1]
|
||||
c += p[i+2]
|
||||
d += p[i+3]
|
||||
e += p[i+4]
|
||||
f += p[i+5]
|
||||
g += p[i+6]
|
||||
h += p[i+7]
|
||||
}
|
||||
for ; i < len(p); i++ {
|
||||
a += p[i]
|
||||
}
|
||||
return a + b + c + d + e + f + g + h
|
||||
}
|
||||
|
||||
// sinkValue keeps the sums alive, so the reads are not optimised away.
|
||||
var sinkValue struct {
|
||||
sync.Mutex
|
||||
v uint64
|
||||
}
|
||||
|
||||
func sink(v uint64) {
|
||||
sinkValue.Lock()
|
||||
sinkValue.v += v
|
||||
sinkValue.Unlock()
|
||||
}
|
||||
|
||||
// memAvailable is MemAvailable in bytes, 0 when unknown.
|
||||
func memAvailable(path string) int64 {
|
||||
f, err := os.Open(path) //nolint:gosec // a fixed system path
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
defer f.Close()
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
fields := strings.Fields(sc.Text())
|
||||
if len(fields) >= 2 && fields[0] == "MemAvailable:" {
|
||||
kb, _ := strconv.ParseInt(fields[1], 10, 64)
|
||||
return kb << 10
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// lastCacheBytes is the largest cache any CPU reports, 0 when unknown.
|
||||
func lastCacheBytes(root string) int64 {
|
||||
paths, _ := filepath.Glob(filepath.Join(root, "cpu*", "cache", "index*", "size"))
|
||||
var most int64
|
||||
for _, p := range paths {
|
||||
b, err := os.ReadFile(p) //nolint:gosec // under /sys
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
v := strings.TrimSpace(string(b))
|
||||
mult := int64(1)
|
||||
switch {
|
||||
case strings.HasSuffix(v, "K"):
|
||||
mult, v = 1<<10, strings.TrimSuffix(v, "K")
|
||||
case strings.HasSuffix(v, "M"):
|
||||
mult, v = 1<<20, strings.TrimSuffix(v, "M")
|
||||
}
|
||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
||||
most = max(most, n*mult)
|
||||
}
|
||||
}
|
||||
return most
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMemoryBandwidthIsMeasured(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("reads half a gigabyte")
|
||||
}
|
||||
bw := MeasureMemoryBandwidth()
|
||||
// Any machine running this reads more than a gigabyte a second and less
|
||||
// than a few terabytes.
|
||||
if bw < 1 || bw > 5000 {
|
||||
t.Errorf("measured %.1f GB/s", bw)
|
||||
}
|
||||
t.Logf("%.1f GB/s", bw)
|
||||
}
|
||||
|
||||
func TestTheLastCacheIsReadFromSys(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for path, size := range map[string]string{"cpu0/cache/index0/size": "48K", "cpu0/cache/index3/size": "96M"} {
|
||||
p := filepath.Join(root, path)
|
||||
_ = os.MkdirAll(filepath.Dir(p), 0o755)
|
||||
_ = os.WriteFile(p, []byte(size+"\n"), 0o644)
|
||||
}
|
||||
if got := lastCacheBytes(root); got != 96<<20 {
|
||||
t.Errorf("%d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/csv"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// A card in the wrong slot: an x16 card in a slot wired x8 or x4, behind a
|
||||
// riser, or in a slot of an older PCIe generation than the card's, links
|
||||
// slower than it was built to, and nothing says so. A model on the card
|
||||
// still decodes at the card's own memory speed, but it loads slower, a
|
||||
// tensor-parallel replica over PCIe is slower, and so is anything that
|
||||
// spills to the host. The driver says how the link stands and what the
|
||||
// card could do; the difference is reported where the node's warnings are.
|
||||
|
||||
// PCIeLink is how a card is linked to its host.
|
||||
type PCIeLink struct {
|
||||
// Gen is the fastest generation the card and the slot together allow —
|
||||
// not the current one, which drops while the card idles, to save power.
|
||||
// GPUGen is the card's own fastest, 0 when the driver does not say.
|
||||
Gen int `json:"gen,omitempty"`
|
||||
GPUGen int `json:"gpu_gen,omitempty"`
|
||||
// Width is the lanes in use now, WidthMax the card's most.
|
||||
Width int `json:"width,omitempty"`
|
||||
WidthMax int `json:"width_max,omitempty"`
|
||||
}
|
||||
|
||||
// laneGBs is one lane's usable rate in GB/s, by generation.
|
||||
var laneGBs = map[int]float64{1: 0.25, 2: 0.5, 3: 0.985, 4: 1.969, 5: 3.938, 6: 7.877}
|
||||
|
||||
// GBs is what a link of gen and width carries, in GB/s each way.
|
||||
func pcieGBs(gen, width int) float64 { return laneGBs[gen] * float64(width) }
|
||||
|
||||
// Problem says how a card's link falls short of what the card could do,
|
||||
// empty when it does not or the driver did not say.
|
||||
func (p PCIeLink) Problem() string {
|
||||
gen := p.Gen
|
||||
if p.GPUGen > 0 && gen == 0 {
|
||||
gen = p.GPUGen
|
||||
}
|
||||
slowGen := p.GPUGen > 0 && p.Gen > 0 && p.Gen < p.GPUGen
|
||||
narrow := p.WidthMax > 0 && p.Width > 0 && p.Width < p.WidthMax
|
||||
if !slowGen && !narrow {
|
||||
return ""
|
||||
}
|
||||
could := pcieGBs(max(p.GPUGen, gen), p.WidthMax)
|
||||
has := pcieGBs(gen, p.Width)
|
||||
var why []string
|
||||
if narrow {
|
||||
why = append(why, fmt.Sprintf("x%d of its x%d lanes", p.Width, p.WidthMax))
|
||||
}
|
||||
if slowGen {
|
||||
why = append(why, fmt.Sprintf("PCIe Gen%d of its Gen%d", p.Gen, p.GPUGen))
|
||||
}
|
||||
return fmt.Sprintf("linked at %s: %.1f GB/s where it could have %.1f. "+
|
||||
"Check the slot (wired fewer lanes, or an older generation), a riser, or the BIOS's PCIe setting",
|
||||
strings.Join(why, " and "), has, could)
|
||||
}
|
||||
|
||||
// pcieFields are asked apart from the rest, and the card's own fastest
|
||||
// generation apart again: a driver that does not know a field refuses the
|
||||
// whole query, and these must not cost a node its cards.
|
||||
var pcieFields = [][]string{
|
||||
{"index", "pcie.link.gen.max", "pcie.link.width.current", "pcie.link.width.max", "pcie.link.gen.gpumax"},
|
||||
{"index", "pcie.link.gen.max", "pcie.link.width.current", "pcie.link.width.max"},
|
||||
}
|
||||
|
||||
// readPCIe fills each card's link from nvidia-smi, leaving it empty when
|
||||
// the driver says nothing of it.
|
||||
func readPCIe(ctx context.Context, binary string, cards []GPU) {
|
||||
for _, fields := range pcieFields {
|
||||
out, err := exec.CommandContext(ctx, binary, "--query-gpu="+strings.Join(fields, ","),
|
||||
"--format=csv,noheader,nounits").Output()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
links := parsePCIe(string(out))
|
||||
for i := range cards {
|
||||
if l, ok := links[cards[i].Index]; ok && cards[i].MIG == nil {
|
||||
cards[i].PCIe = l
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
for i := range cards {
|
||||
if cards[i].MIG == nil {
|
||||
cards[i].PCIe = withSysfs(cards[i].PCIe, "/sys/bus/pci/devices", cards[i].PCIBusID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// withSysfs takes the card's own lane count, and its own fastest
|
||||
// generation, from the kernel: the driver's width.max is what card and slot
|
||||
// allow together, so a x16 card in a slot wired x8 reads x8 of x8 there,
|
||||
// while the device's max_link_width is the card's, 16.
|
||||
func withSysfs(l PCIeLink, root, busID string) PCIeLink {
|
||||
dev := sysfsBusID(busID)
|
||||
if dev == "" {
|
||||
return l
|
||||
}
|
||||
read := func(name string) string {
|
||||
b, err := os.ReadFile(filepath.Join(root, dev, name)) //nolint:gosec // under /sys
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
if n, err := strconv.Atoi(read("max_link_width")); err == nil && n > 0 {
|
||||
l.WidthMax = n
|
||||
}
|
||||
if n, err := strconv.Atoi(read("current_link_width")); err == nil && n > 0 {
|
||||
l.Width = n
|
||||
}
|
||||
if g := genOfSpeed(read("max_link_speed")); g > 0 && l.GPUGen == 0 {
|
||||
l.GPUGen = g
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// sysfsBusID is nvidia-smi's 00000000:3B:00.0 as the kernel names it,
|
||||
// 0000:3b:00.0.
|
||||
func sysfsBusID(id string) string {
|
||||
id = strings.ToLower(strings.TrimSpace(id))
|
||||
dom, rest, ok := strings.Cut(id, ":")
|
||||
if !ok || len(dom) < 4 {
|
||||
return ""
|
||||
}
|
||||
return dom[len(dom)-4:] + ":" + rest
|
||||
}
|
||||
|
||||
// genOfSpeed is the PCIe generation of a link speed as sysfs writes it.
|
||||
func genOfSpeed(s string) int {
|
||||
switch {
|
||||
case strings.HasPrefix(s, "2.5 GT/s"):
|
||||
return 1
|
||||
case strings.HasPrefix(s, "5.0 GT/s"), strings.HasPrefix(s, "5 GT/s"):
|
||||
return 2
|
||||
case strings.HasPrefix(s, "8.0 GT/s"), strings.HasPrefix(s, "8 GT/s"):
|
||||
return 3
|
||||
case strings.HasPrefix(s, "16.0 GT/s"), strings.HasPrefix(s, "16 GT/s"):
|
||||
return 4
|
||||
case strings.HasPrefix(s, "32.0 GT/s"), strings.HasPrefix(s, "32 GT/s"):
|
||||
return 5
|
||||
case strings.HasPrefix(s, "64.0 GT/s"), strings.HasPrefix(s, "64 GT/s"):
|
||||
return 6
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func parsePCIe(out string) map[int]PCIeLink {
|
||||
r := csv.NewReader(strings.NewReader(out))
|
||||
r.TrimLeadingSpace = true
|
||||
r.FieldsPerRecord = -1
|
||||
rows, _ := r.ReadAll()
|
||||
num := func(s string) int {
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||
return n
|
||||
}
|
||||
links := map[int]PCIeLink{}
|
||||
for _, row := range rows {
|
||||
if len(row) < 4 {
|
||||
continue
|
||||
}
|
||||
idx, err := strconv.Atoi(strings.TrimSpace(row[0]))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
l := PCIeLink{Gen: num(row[1]), Width: num(row[2]), WidthMax: num(row[3])}
|
||||
if len(row) > 4 {
|
||||
l.GPUGen = num(row[4])
|
||||
}
|
||||
links[idx] = l
|
||||
}
|
||||
return links
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package discovery
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestACardInTheWrongSlotIsSaidSo(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
link PCIeLink
|
||||
want string // empty: nothing to say
|
||||
}{
|
||||
// The PoC's Quadro RTX 6000, idle: current Gen1, but its fastest is 3,
|
||||
// as the card's is, on all 16 lanes. Nothing wrong.
|
||||
{PCIeLink{Gen: 3, GPUGen: 3, Width: 16, WidthMax: 16}, ""},
|
||||
// An x16 card in a slot wired x8.
|
||||
{PCIeLink{Gen: 4, GPUGen: 4, Width: 8, WidthMax: 16}, "x8 of its x16 lanes: 15.8 GB/s where it could have 31.5"},
|
||||
// A Gen5 card in a Gen4 slot.
|
||||
{PCIeLink{Gen: 4, GPUGen: 5, Width: 16, WidthMax: 16}, "PCIe Gen4 of its Gen5: 31.5 GB/s where it could have 63.0"},
|
||||
// Both, behind a riser.
|
||||
{PCIeLink{Gen: 3, GPUGen: 4, Width: 4, WidthMax: 16}, "x4 of its x16 lanes and PCIe Gen3 of its Gen4"},
|
||||
// A driver that says nothing.
|
||||
{PCIeLink{}, ""},
|
||||
} {
|
||||
got := c.link.Problem()
|
||||
if (c.want == "") != (got == "") || !strings.Contains(got, c.want) {
|
||||
t.Errorf("%+v: %q, want %q", c.link, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheLinkIsReadFromNvidiaSMI(t *testing.T) {
|
||||
links := parsePCIe("0, 3, 16, 16, 3\n1, 4, 8, 16, [N/A]\n")
|
||||
if links[0] != (PCIeLink{Gen: 3, GPUGen: 3, Width: 16, WidthMax: 16}) || links[1] != (PCIeLink{Gen: 4, Width: 8, WidthMax: 16}) {
|
||||
t.Errorf("%+v", links)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheCardsOwnLanesComeFromTheKernel(t *testing.T) {
|
||||
// The driver says x8 of x8 for a x16 card in a slot wired x8; the
|
||||
// device's own max_link_width says 16.
|
||||
root := t.TempDir()
|
||||
dev := filepath.Join(root, "0000:3b:00.0")
|
||||
_ = os.MkdirAll(dev, 0o755)
|
||||
for f, v := range map[string]string{"max_link_width": "16", "current_link_width": "8", "max_link_speed": "16.0 GT/s PCIe"} {
|
||||
_ = os.WriteFile(filepath.Join(dev, f), []byte(v+"\n"), 0o644)
|
||||
}
|
||||
l := withSysfs(PCIeLink{Gen: 4, Width: 8, WidthMax: 8}, root, "00000000:3B:00.0")
|
||||
if l.WidthMax != 16 || l.Width != 8 || l.GPUGen != 4 || !strings.Contains(l.Problem(), "x8 of its x16") {
|
||||
t.Errorf("%+v: %q", l, l.Problem())
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,13 @@ func NewSensorSampler() *SensorSampler {
|
||||
return &SensorSampler{sys: "/sys", smi: runSMI, now: time.Now}
|
||||
}
|
||||
|
||||
// SampleGPUs reads every card's heat, power and how busy it is now, without
|
||||
// a sampler's ten seconds of memory: for a screen that asks every few seconds
|
||||
// and wants what it asked for. Nil on a machine without nvidia-smi or a card.
|
||||
func SampleGPUs(ctx context.Context) []GPUSensor {
|
||||
return (&SensorSampler{smi: runSMI}).gpus(ctx)
|
||||
}
|
||||
|
||||
func runSMI(ctx context.Context, fields string) ([]byte, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -47,6 +47,9 @@ type Profile struct {
|
||||
// KVEvents has the engine publish its KV cache events to the agent,
|
||||
// for the router's precise index. vLLM only.
|
||||
KVEvents bool `json:"kv_events,omitempty"`
|
||||
// AllowPickle lets the node fetch weights in a pickle format, which run
|
||||
// code when loaded; the catalog's allow_pickle.
|
||||
AllowPickle bool `json:"allow_pickle,omitempty"`
|
||||
}
|
||||
|
||||
// ShardSpec is one shard of one replica on this node.
|
||||
@@ -215,6 +218,7 @@ func NewRegistry(cfg Config) *Registry {
|
||||
r.Add(&TRTLLM{cfg: cfg.TRTLLM})
|
||||
r.Add(&Sim{cfg: cfg.Sim})
|
||||
r.Add(&ComfyUI{cfg: cfg.ComfyUI})
|
||||
r.Add(&Strata{cfg: cfg.Strata})
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -265,6 +269,9 @@ type Config struct {
|
||||
Sim SimConfig `koanf:"sim"`
|
||||
// ComfyUI makes pictures; offered by a node whose ComfyUI is named here.
|
||||
ComfyUI ComfyUIConfig `koanf:"comfyui"`
|
||||
// Strata serves a few mixture-of-experts models from RAM and one card;
|
||||
// offered by a node whose Strata is named here, or that installed it.
|
||||
Strata StrataConfig `koanf:"strata"`
|
||||
}
|
||||
|
||||
// --- shared helpers ---
|
||||
|
||||
@@ -477,14 +477,14 @@ 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", "trtllm", "vllm"}) {
|
||||
if got := r.Names(); !slices.Equal(got, []string{"comfyui", "llamacpp", "sglang", "sim", "strata", "trtllm", "vllm"}) {
|
||||
t.Errorf("registry contents: %v", got)
|
||||
}
|
||||
if _, err := r.Get("vllm"); err != nil {
|
||||
t.Errorf("vllm missing: %v", err)
|
||||
}
|
||||
err := func() error { _, err := r.Get("nope"); return err }()
|
||||
if err == nil || !strings.Contains(err.Error(), "llamacpp, sglang, sim, trtllm, vllm") {
|
||||
if err == nil || !strings.Contains(err.Error(), "llamacpp, sglang, sim, strata, trtllm, vllm") {
|
||||
t.Errorf("an unknown engine should list what is available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAModelDownloadedByTheEngineIsReadAsSafetensorsOnly(t *testing.T) {
|
||||
for path, want := range map[string]bool{
|
||||
"Qwen/Qwen2.5-7B-Instruct": true,
|
||||
"/var/lib/llmgw/weights/abc/": false, "./local": false,
|
||||
"http://m/q/v/": false, "q.gguf": false,
|
||||
} {
|
||||
got := slices.Contains(safetensorsOnly(Profile{ModelPath: path}), "safetensors")
|
||||
if got != want {
|
||||
t.Errorf("%q: safetensors only = %v, want %v", path, got, want)
|
||||
}
|
||||
}
|
||||
if len(safetensorsOnly(Profile{ModelPath: "Qwen/Qwen2.5-7B-Instruct", AllowPickle: true})) != 0 {
|
||||
t.Error("allow_pickle still forced safetensors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAForbiddenFlagIsKnownHoweverItIsSpelled(t *testing.T) {
|
||||
for engine, args := range map[string][]string{
|
||||
"vllm": {"--trust_remote_code"},
|
||||
"sglang": {"--load-format", "pt"},
|
||||
} {
|
||||
if UnsafeArgs(engine, args) == nil {
|
||||
t.Errorf("%s %v was let through", engine, args)
|
||||
}
|
||||
}
|
||||
for _, a := range [][]string{{"--load_format", "pt"}, {"--trust-remote"}, {"--load-form=pt"}} {
|
||||
if UnsafeArgs("vllm", a) == nil {
|
||||
t.Errorf("vllm %v was let through", a)
|
||||
}
|
||||
}
|
||||
// What is not one of them, nor the start of one, still passes.
|
||||
for _, a := range [][]string{{"--max-model-len", "8192"}, {"--tokenizer-mode", "mistral"}, {"--gpu-memory-utilization=0.9"}} {
|
||||
if err := UnsafeArgs("vllm", a); err != nil {
|
||||
t.Errorf("vllm %v was refused: %v", a, err)
|
||||
}
|
||||
}
|
||||
// Short flags are matched as written; llama.cpp takes no abbreviations,
|
||||
// so its --chat-template is not taken for --chat-template-file.
|
||||
for _, a := range [][]string{{"-m", "x"}, {"-hf", "o/r"}, {"-mu", "http://x"}} {
|
||||
if UnsafeArgs("llamacpp", a) == nil {
|
||||
t.Errorf("llamacpp %v was let through", a)
|
||||
}
|
||||
}
|
||||
if err := UnsafeArgs("llamacpp", []string{"--chat-template", "chatml"}); err != nil {
|
||||
t.Errorf("llama.cpp's own --chat-template was refused: %v", err)
|
||||
}
|
||||
// ComfyUI's argparse reads --li as --listen.
|
||||
if UnsafeArgs("comfyui", []string{"--li", "0.0.0.0"}) == nil {
|
||||
t.Error("comfyui --li was let through")
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,7 @@ func (g *SGLang) serverCommand(s ShardSpec) (Command, error) {
|
||||
if p.ServedModelName != "" {
|
||||
args = append(args, "--served-model-name", p.ServedModelName)
|
||||
}
|
||||
args = append(args, safetensorsOnly(p)...)
|
||||
if s.NNodes > 1 {
|
||||
args = append(args,
|
||||
"--nnodes", strconv.Itoa(s.NNodes),
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"sync/atomic"
|
||||
)
|
||||
|
||||
// StrataConfig locates a Strata installation.
|
||||
type StrataConfig struct {
|
||||
// Dir is Strata's source directory, where setup.py and serve/ are.
|
||||
Dir string `koanf:"dir"`
|
||||
// Python is the interpreter of its environment; Dir's .venv when unset.
|
||||
Python string `koanf:"python"`
|
||||
// Engine is the directory of its prebuilt engine, where the strata
|
||||
// binary and the CUDA libraries it ships are; Dir's engine/ when unset.
|
||||
Engine string `koanf:"engine"`
|
||||
}
|
||||
|
||||
// Strata runs Strata (github.com/Niko1221/Strata): a mixture-of-experts
|
||||
// engine that keeps a model's experts in RAM and the ones used most in VRAM,
|
||||
// so a 125B model serves from one 24 GB card. It runs a few model families
|
||||
// only, each prepared from its GGUF files by Strata's own setup, so the agent
|
||||
// starts llmgw strata-serve, which prepares the model and then becomes
|
||||
// Strata's server.
|
||||
type Strata struct {
|
||||
cfg StrataConfig
|
||||
installed atomic.Pointer[string]
|
||||
}
|
||||
|
||||
// Name identifies the engine in the catalog.
|
||||
func (*Strata) Name() string { return "strata" }
|
||||
|
||||
// HealthPath answers 200 only once the model is loaded.
|
||||
func (*Strata) HealthPath() string { return "/health" }
|
||||
|
||||
// MetricsPath is Strata's Prometheus text, in vLLM's names; its /metrics is
|
||||
// JSON for its own monitor unless asked for this format.
|
||||
func (*Strata) MetricsPath() string { return "/metrics?format=prometheus" }
|
||||
|
||||
// ServesHTTP is true: a Strata replica is one process on one node.
|
||||
func (*Strata) ServesHTTP(ShardSpec) bool { return true }
|
||||
|
||||
// UseInstalled points the engine at the environment the agent installed:
|
||||
// its interpreter, with Strata's code beside its bin in strata/ and the
|
||||
// engine in strata-engine/.
|
||||
func (s *Strata) UseInstalled(python string) { s.installed.Store(&python) }
|
||||
|
||||
// Configured reports whether the configuration names a Strata.
|
||||
func (s *Strata) Configured() bool { return s.cfg.Dir != "" }
|
||||
|
||||
// StrataSourceDir and StrataEngineDir are where an installed environment
|
||||
// keeps Strata's code and its prebuilt engine, beside bin/.
|
||||
const (
|
||||
StrataSourceDir = "strata"
|
||||
StrataEngineDir = "strata-engine"
|
||||
)
|
||||
|
||||
func (s *Strata) env() string {
|
||||
if p := s.installed.Load(); p != nil {
|
||||
return filepath.Dir(filepath.Dir(*p))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *Strata) dir() string {
|
||||
if e := s.env(); e != "" {
|
||||
return filepath.Join(e, StrataSourceDir)
|
||||
}
|
||||
return s.cfg.Dir
|
||||
}
|
||||
|
||||
func (s *Strata) engineDir() string {
|
||||
if e := s.env(); e != "" {
|
||||
return filepath.Join(e, StrataEngineDir)
|
||||
}
|
||||
if s.cfg.Engine != "" {
|
||||
return s.cfg.Engine
|
||||
}
|
||||
return filepath.Join(s.cfg.Dir, "engine")
|
||||
}
|
||||
|
||||
func (s *Strata) python() string {
|
||||
if p := s.installed.Load(); p != nil {
|
||||
return *p
|
||||
}
|
||||
if s.cfg.Python != "" {
|
||||
return s.cfg.Python
|
||||
}
|
||||
return filepath.Join(s.cfg.Dir, ".venv", "bin", "python")
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return err
|
||||
}
|
||||
return validateCommon(p, gpus)
|
||||
}
|
||||
|
||||
// Commands is llmgw strata-serve, which prepares the model with Strata's
|
||||
// setup and then execs Strata's server.
|
||||
func (s *Strata) Commands(sh ShardSpec) ([]Command, error) {
|
||||
dir := s.dir()
|
||||
if dir == "" {
|
||||
return nil, fmt.Errorf("strata: no Strata directory is configured (engines.strata.dir)")
|
||||
}
|
||||
bin, err := os.Executable()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p := sh.Profile
|
||||
args := []string{"strata-serve",
|
||||
"--listen", net.JoinHostPort(sh.Host(), strconv.Itoa(sh.HTTPPort)),
|
||||
"--model", p.ServedModelName,
|
||||
"--weights", p.ModelPath,
|
||||
// Relative to where engines run — their user's home — so they are
|
||||
// theirs to write. The data is shared by the node's replicas: a
|
||||
// prepared model and its draft layer are gigabytes, the same for
|
||||
// every replica of it.
|
||||
"--state", filepath.Join("strata", sh.ReplicaID),
|
||||
"--data", filepath.Join("strata", "data"),
|
||||
"--python", s.python(),
|
||||
"--strata", dir,
|
||||
"--engine", s.engineDir(),
|
||||
"--",
|
||||
}
|
||||
args = append(args, p.Args...)
|
||||
env := baseEnv(sh)
|
||||
if sh.APIKey != "" {
|
||||
// Strata's server reads its key from here, and strata-serve passes
|
||||
// it on in the environment: not on a command line every user on the
|
||||
// node can read.
|
||||
env["STRATA_API_KEY"] = sh.APIKey
|
||||
}
|
||||
return []Command{{Name: "strata", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package engines
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStrataStartsStrataServeWithTheKeyInTheEnvironment(t *testing.T) {
|
||||
s := &Strata{}
|
||||
s.UseInstalled("/opt/llmgw/engines/strata/0.2.7-cuda13/bin/python")
|
||||
spec := ShardSpec{
|
||||
ReplicaID: "r1", HTTPPort: 18090, BindAddr: "10.0.0.147", GPUIndices: []int{0},
|
||||
APIKey: "secret-key",
|
||||
Profile: Profile{Name: "p", Engine: "strata", TP: 1, ModelPath: "/var/lib/llmgw/models/q38",
|
||||
ServedModelName: "qwen3.8-flash-next", Args: []string{"--family", "qwen", "--size", "IQ2_XS"}},
|
||||
}
|
||||
cmds, err := s.Commands(spec)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c := cmds[0]
|
||||
args := strings.Join(c.Args, " ")
|
||||
env := "/opt/llmgw/engines/strata/0.2.7-cuda13"
|
||||
for _, want := range []string{
|
||||
"strata-serve", "--listen 10.0.0.147:18090", "--model qwen3.8-flash-next",
|
||||
"--weights /var/lib/llmgw/models/q38", "--state " + filepath.Join("strata", "r1"),
|
||||
"--python " + env + "/bin/python", "--strata " + env + "/strata", "--engine " + env + "/strata-engine",
|
||||
"-- --family qwen --size IQ2_XS",
|
||||
} {
|
||||
if !strings.Contains(args, want) {
|
||||
t.Errorf("args lack %q: %s", want, args)
|
||||
}
|
||||
}
|
||||
if strings.Contains(args, "secret-key") {
|
||||
t.Error("the key is on the command line, which every user on the node can read")
|
||||
}
|
||||
if !slices.Contains(c.Env, "STRATA_API_KEY=secret-key") {
|
||||
t.Error("the key is not in the environment, where Strata's server reads it")
|
||||
}
|
||||
if !slices.Contains(c.Env, "CUDA_VISIBLE_DEVICES=0") {
|
||||
t.Error("the shard is not pinned to its GPU")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataRunsOnOneCard(t *testing.T) {
|
||||
s := &Strata{}
|
||||
p := Profile{Name: "p", Engine: "strata", TP: 1, GPUsPerNode: 2, ModelPath: "/m"}
|
||||
if err := s.Validate(p, nil); err == nil {
|
||||
t.Error("a two-card profile was accepted")
|
||||
}
|
||||
p.GPUsPerNode = 1
|
||||
if err := s.Validate(p, []GPUInfo{{Name: "Quadro RTX 6000", ComputeCapability: "7.5"}}); err != nil {
|
||||
t.Errorf("one Turing card was refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataSetupFlagsThatMoveFilesAreRefused(t *testing.T) {
|
||||
for _, bad := range []string{"--gguf-dir", "--gguf", "--data-dir=/etc", "--model", "--mod", "--feed", "--host", "--gpus"} {
|
||||
if err := UnsafeArgs("strata", []string{bad, "x"}); err == nil {
|
||||
t.Errorf("%s was accepted", bad)
|
||||
}
|
||||
}
|
||||
if err := UnsafeArgs("strata", []string{"--family", "qwen", "--size", "IQ2_XS", "--context", "32768",
|
||||
"--parallel", "2", "--vram-reserve-mib", "2048", "--vision", "no"}); err != nil {
|
||||
t.Errorf("an ordinary profile was refused: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package engines
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -101,6 +102,13 @@ var flagsNever = map[string]map[string]string{
|
||||
"--hf-token": "is a credential", "--alias": "is set by the agent",
|
||||
"--model-draft": "loads a file", "-md": "loads a file",
|
||||
"--prompt-cache": "writes a file", "--logdir": "writes files",
|
||||
// The short spellings of the same.
|
||||
"-mm": "loads a file; the projector is fetched with the model", "--mmproj-url": "downloads a file",
|
||||
"-mmu": "downloads a file", "-hfr": "downloads a model", "-hff": "downloads a model",
|
||||
"--hf-repo-draft": "downloads a model", "-hfd": "downloads a model", "-hfrd": "downloads a model",
|
||||
"--hf-repo-v": "downloads a model", "-hfv": "downloads a model", "--hf-file-v": "downloads a model",
|
||||
"-hffv": "downloads a model", "-hft": "is a credential", "-jf": "reads a file", "-a": "is set by the agent",
|
||||
"--model-vocoder": "loads a file", "-mv": "loads a file",
|
||||
},
|
||||
"vllm": {
|
||||
"--download-dir": "writes files", "--tokenizer": "reads another repository or path",
|
||||
@@ -118,6 +126,7 @@ var flagsNever = map[string]map[string]string{
|
||||
"--file-storage-path": "writes files", "--log-requests": "logs prompts",
|
||||
"--model-path": "is the model's source, set by the agent", "--served-model-name": "is set by the agent",
|
||||
"--lora-paths": "loads files", "--download-dir": "writes files",
|
||||
"--load-format": "changes how weights are loaded, some ways running code",
|
||||
},
|
||||
// ComfyUI's main.py, behind the gate that sets where it listens and
|
||||
// where its files are.
|
||||
@@ -132,6 +141,22 @@ var flagsNever = map[string]map[string]string{
|
||||
"--tls-keyfile": "reads a file", "--tls-certfile": "reads a file",
|
||||
"--comfy-api-base": "sends work to another service",
|
||||
},
|
||||
// Strata's setup.py, which a profile's arguments go to: llmgw
|
||||
// strata-serve sets where the model is, where its files go and where
|
||||
// the server listens, and the engine is the one the agent installed.
|
||||
// Its own --model, the size, is spelt --size in a profile, since
|
||||
// --model is the source everywhere else.
|
||||
"strata": {
|
||||
"--gguf-dir": "is the model's source, set by the agent", "--models-dir": "is set by the agent",
|
||||
"--data-dir": "is set by the agent", "--move-data": "moves files", "--set-default-data-dir": "writes settings",
|
||||
"--feed": "downloads an engine", "--prebuilt": "loads an engine", "--build": "compiles an engine",
|
||||
"--update": "downloads an engine", "--rollback-engine": "changes the engine",
|
||||
"--dsv41-package": "loads a file", "--inspect": "reads another repository or path",
|
||||
"--source": "downloads from elsewhere", "--setup": "is not for a server",
|
||||
"--browser": "opens a browser", "--gpu": "is set by the agent", "--gpus": "is set by the agent",
|
||||
"--gpu-roles": "is set by the agent", "--backend": "is set by the agent",
|
||||
"--experimental-speed-projection": "changes what the model refuses",
|
||||
},
|
||||
// trtllm-serve spells its flags with underscores, so the shared list's
|
||||
// hyphenated names do not reach them.
|
||||
"trtllm": {
|
||||
@@ -163,14 +188,43 @@ var flagsNever = map[string]map[string]string{
|
||||
// unsafeFlag is why an engine may not be given a flag, or "".
|
||||
func unsafeFlag(engine, flag, _ string) string {
|
||||
name, _, _ := strings.Cut(flag, "=")
|
||||
if !strings.HasPrefix(name, "-") {
|
||||
return ""
|
||||
}
|
||||
// Written as the engine would read it: vLLM takes --load_format for
|
||||
// --load-format, TensorRT-LLM the other way round. Short flags (-m,
|
||||
// -hf) are matched as written.
|
||||
norm := func(f string) string {
|
||||
if strings.HasPrefix(f, "--") {
|
||||
return "--" + strings.ReplaceAll(f[2:], "_", "-")
|
||||
}
|
||||
return f
|
||||
}
|
||||
given := norm(name)
|
||||
// argparse also takes any unambiguous beginning of a long flag for the
|
||||
// flag — --trust-remote is --trust-remote-code, --li is ComfyUI's
|
||||
// --listen — so for the engines that parse with it, a beginning of a
|
||||
// refused flag is refused. llama.cpp and TensorRT-LLM take no such
|
||||
// thing, and --chat-template there is a flag of its own.
|
||||
abbreviates := argparseEngines[engine] && strings.HasPrefix(given, "--") && len(given) >= 4
|
||||
for _, set := range []string{"", engine} {
|
||||
if why, ok := flagsNever[set][name]; ok && why != "" {
|
||||
return why
|
||||
for never, why := range flagsNever[set] {
|
||||
if why == "" {
|
||||
continue
|
||||
}
|
||||
n := norm(never)
|
||||
if n == given || (abbreviates && strings.HasPrefix(n, "--") && strings.HasPrefix(n, given)) {
|
||||
return why
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// argparseEngines read their flags with Python's argparse, which takes a
|
||||
// long flag's beginning for the flag.
|
||||
var argparseEngines = map[string]bool{"vllm": true, "sglang": true, "comfyui": true, "strata": true}
|
||||
|
||||
// UnsafeArgs refuses the first of a profile's arguments an engine may not be
|
||||
// given, saying why.
|
||||
func UnsafeArgs(engine string, args []string) error {
|
||||
@@ -185,3 +239,29 @@ func UnsafeArgs(engine string, args []string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// safetensorsOnly has vLLM or SGLang read only safetensors from a model it
|
||||
// downloads itself, by its Hugging Face id: left to choose, either takes
|
||||
// the repository's pickle files (pytorch_model.bin) when it has no
|
||||
// safetensors, and loading those runs whatever they hold. A path was
|
||||
// fetched by the agent, which checked it, or put there by the operator.
|
||||
func safetensorsOnly(p Profile) []string {
|
||||
if p.AllowPickle || !hubID(p.ModelPath) {
|
||||
return nil
|
||||
}
|
||||
return []string{"--load-format", "safetensors"}
|
||||
}
|
||||
|
||||
// hubID reports whether a model path is a Hugging Face id, org/name, rather
|
||||
// than a file or directory.
|
||||
func hubID(s string) bool {
|
||||
if s == "" || strings.HasPrefix(s, "/") || strings.HasPrefix(s, ".") || strings.Contains(s, "://") {
|
||||
return false
|
||||
}
|
||||
// org/name:Q4_K_M is a GGUF in a repository, which vLLM reads as one.
|
||||
if strings.Count(s, "/") != 1 || strings.Contains(s, ":") {
|
||||
return false
|
||||
}
|
||||
_, err := os.Stat(s)
|
||||
return err != nil
|
||||
}
|
||||
@@ -119,6 +119,7 @@ func (v *VLLM) serverCommand(s ShardSpec) (Command, error) {
|
||||
if p.ServedModelName != "" {
|
||||
args = append(args, "--served-model-name", p.ServedModelName)
|
||||
}
|
||||
args = append(args, safetensorsOnly(p)...)
|
||||
|
||||
env := baseEnv(s)
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ var (
|
||||
gpuProcesses = discovery.GPUProcesses
|
||||
gpuMemory = discovery.GPUMemoryByProcess
|
||||
processGroup = pgidOf
|
||||
parentOf = ppidOf
|
||||
// gpuCheckWait is how long a shard has, once it answers, to show up on
|
||||
// its GPUs: the weights are on them by then, but nvidia-smi can lag.
|
||||
gpuCheckWait = 15 * time.Second
|
||||
@@ -116,19 +117,44 @@ func (a *Agent) foreignGPUMemory(ctx context.Context, shards []int) (foreign, us
|
||||
return foreign, used
|
||||
}
|
||||
|
||||
// holds reports whether any of pids is the shard's process or in its group:
|
||||
// an engine that computes in a child process is still the shard.
|
||||
// holds reports whether any of pids is the shard's process, in its group,
|
||||
// or a process it started: an engine that computes in a child process is
|
||||
// still the shard, and Strata's server starts its engine in a group of the
|
||||
// engine's own.
|
||||
func holds(pids []int, pid, group int) bool {
|
||||
for _, p := range pids {
|
||||
if p == pid || (group > 0 && processGroup(p) == group) {
|
||||
if p == pid || (group > 0 && processGroup(p) == group) || descends(p, pid) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// descends reports whether process p was started, at any remove, by pid.
|
||||
func descends(p, pid int) bool {
|
||||
// Bounded: a chain this deep is no engine's, and a /proc read racing an
|
||||
// exit must not loop.
|
||||
for range 16 {
|
||||
p = parentOf(p)
|
||||
switch {
|
||||
case p == pid:
|
||||
return true
|
||||
case p <= 1:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// pgidOf reads a process's group from /proc; zero when it cannot.
|
||||
func pgidOf(pid int) int {
|
||||
func pgidOf(pid int) int { return statField(pid, 2) }
|
||||
|
||||
// ppidOf reads a process's parent from /proc; zero when it cannot.
|
||||
func ppidOf(pid int) int { return statField(pid, 1) }
|
||||
|
||||
// statField is one of the numbers after the command name in /proc's stat
|
||||
// line: 1 is the parent, 2 the group.
|
||||
func statField(pid, field int) int {
|
||||
b, err := os.ReadFile("/proc/" + strconv.Itoa(pid) + "/stat")
|
||||
if err != nil {
|
||||
return 0
|
||||
@@ -141,10 +167,10 @@ func pgidOf(pid int) int {
|
||||
return 0
|
||||
}
|
||||
f := strings.Fields(s[i+1:])
|
||||
if len(f) < 3 {
|
||||
if len(f) <= field {
|
||||
return 0
|
||||
}
|
||||
n, _ := strconv.Atoi(f[2])
|
||||
n, _ := strconv.Atoi(f[field])
|
||||
return n
|
||||
}
|
||||
|
||||
|
||||
@@ -123,6 +123,30 @@ func TestForeignGPUMemoryLeavesOutTheAgentsOwnShards(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEngineStartedInAGroupOfItsOwnIsStillTheShards(t *testing.T) {
|
||||
// cuda01: Strata's server (259709) starts its engine (261072) in a
|
||||
// group of the engine's own, and the engine holds the card. It read as
|
||||
// a shard on the CPU, and as 22 GB of somebody else's memory.
|
||||
oldMem, oldGroup, oldParent := gpuMemory, processGroup, parentOf
|
||||
t.Cleanup(func() { gpuMemory, processGroup, parentOf = oldMem, oldGroup, oldParent })
|
||||
processGroup = func(pid int) int { return pid }
|
||||
parentOf = func(pid int) int {
|
||||
return map[int]int{261072: 259709, 259709: 258734, 258734: 1, 999: 1}[pid]
|
||||
}
|
||||
gpuMemory = func(context.Context, string) ([]discovery.GPUMemory, error) {
|
||||
return []discovery.GPUMemory{{Index: 0, UsedMB: 23000,
|
||||
Procs: []discovery.GPUProcess{{PID: 261072, UsedMB: 22226}, {PID: 999, UsedMB: 700}}}}, nil
|
||||
}
|
||||
a := &Agent{log: slog.Default()}
|
||||
if c, ok := a.gpuCompute(context.Background(), []int{0}, 259709); !ok || c.Backend != "CUDA" ||
|
||||
len(c.Devices) != 1 || c.Devices[0] != "GPU 0: 21.7 GB" {
|
||||
t.Errorf("the engine's child on GPU 0: %+v %v", c, ok)
|
||||
}
|
||||
if foreign, _ := a.foreignGPUMemory(context.Background(), []int{259709}); !slices.Equal(foreign, []int32{774}) {
|
||||
t.Errorf("foreign %v; want only what the other process and nobody holds, [774]", foreign)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLlamaCppsOwnWordOnTheCPUIsEnoughToRefuse(t *testing.T) {
|
||||
eng := fallbackEngine{}
|
||||
// Without nvidia-smi to ask, the engine's own output says it.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
// Half a gigabyte read per test binary, and contending with the tests
|
||||
// for the CPUs: these never look at the figure.
|
||||
measureBandwidth = func() float64 { return 0 }
|
||||
bandwidthDelay = func() time.Duration { return 0 }
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
@@ -174,7 +174,7 @@ func (in *Installer) one(ctx context.Context, w Want) Status {
|
||||
}
|
||||
return st
|
||||
}
|
||||
if w.Offline != nil && recipe.Repo == "" {
|
||||
if w.Offline != nil && (recipe.Repo == "" || builds[0].Python != "") {
|
||||
// Said, not attempted: uv would spend minutes failing to reach PyPI,
|
||||
// and the failure would read like a network fault rather than the
|
||||
// design of an offline site.
|
||||
|
||||
@@ -109,6 +109,11 @@ func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Buil
|
||||
}
|
||||
reqs = append(reqs, more...)
|
||||
}
|
||||
if len(b.Assets) > 0 {
|
||||
if err := in.unpackAssets(ctx, r, w, b, tmp, st); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if b.Wheel != nil {
|
||||
// The engine from its GitHub release, pinned to the digest GitHub
|
||||
// publishes for the file: the same trust as an archive, and uv
|
||||
@@ -372,6 +377,48 @@ func (in *Installer) unpackSource(ctx context.Context, w Want, b Build, env stri
|
||||
return requirementsIn(string(f), b.IndexPackages)
|
||||
}
|
||||
|
||||
// unpackAssets fetches a Python engine's release binaries, each checked
|
||||
// against the digest GitHub publishes for it, and unpacks them into the
|
||||
// environment's AssetDir.
|
||||
func (in *Installer) unpackAssets(ctx context.Context, r Recipe, w Want, b Build, env string, st *Status) error {
|
||||
tag := b.AssetTag
|
||||
if tag == "" {
|
||||
tag = w.Version
|
||||
}
|
||||
in.progress(st, "asking GitHub for the checksums of %s %s", r.Repo, tag)
|
||||
sums, err := in.checksums(ctx, r.Repo, tag)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst := filepath.Join(env, b.AssetDir)
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, asset := range b.Assets {
|
||||
want, ok := sums[asset]
|
||||
if !ok {
|
||||
// Refused rather than installed unchecked: this is a binary that
|
||||
// will run on every node.
|
||||
return fmt.Errorf("%s %s publishes no checksum for %s, so it cannot be verified", r.Repo, tag, asset)
|
||||
}
|
||||
url := strings.TrimSuffix(in.downloadBase(), "/") + "/" + r.Repo + "/releases/download/" + tag + "/" + asset
|
||||
archive := filepath.Join(env+".source", asset)
|
||||
if err := os.MkdirAll(filepath.Dir(archive), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.download(ctx, url, asset, archive, want, st); err != nil {
|
||||
return err
|
||||
}
|
||||
in.progress(st, "unpacking %s", asset)
|
||||
err := untarGz(archive, dst)
|
||||
_ = os.RemoveAll(filepath.Dir(archive))
|
||||
if err != nil {
|
||||
return fmt.Errorf("unpacking %s: %w", asset, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// requirementsIn reads a requirements file's lines. Options (-r, -e, --index-url
|
||||
// and the like) are refused: they would fetch from somewhere other than PyPI,
|
||||
// or install what the lock never saw.
|
||||
|
||||
@@ -76,6 +76,11 @@ type Build struct {
|
||||
// was published, which PyPI cannot be asked for.
|
||||
Source *Source
|
||||
AsOf time.Time
|
||||
// AssetTag and AssetDir, for a Python engine that also ships a binary
|
||||
// in its releases, are the release tag Assets are taken from, checked
|
||||
// against the digests GitHub publishes, and the directory in the
|
||||
// environment they are unpacked into.
|
||||
AssetTag, AssetDir string
|
||||
// FreeGB is the disk space the installation needs before it starts: a
|
||||
// Python engine is several gigabytes, and one that fills the disk halfway
|
||||
// leaves the node worse off than one that never began.
|
||||
@@ -125,6 +130,7 @@ var Recipes = map[string]Recipe{
|
||||
"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},
|
||||
}
|
||||
|
||||
@@ -172,6 +178,9 @@ func Builds(engine, version, arch string) ([]Build, error) {
|
||||
continue
|
||||
}
|
||||
for _, b := range bs {
|
||||
if b.Python != "" {
|
||||
return nil, fmt.Errorf("%s is a Python environment from PyPI, which an offline bundle does not carry", engine)
|
||||
}
|
||||
if !seen[b.Variant] {
|
||||
seen[b.Variant] = true
|
||||
out = append(out, b)
|
||||
@@ -672,3 +681,80 @@ print("comfyui version", "` + version + `", "on torch", torch.__version__)`},
|
||||
{cu126, gpuNeeds{minDriverForCUDA12, 5.0}, "ComfyUI " + version + " on CUDA 12.6's torch"},
|
||||
}, h)
|
||||
}
|
||||
|
||||
// --- Strata ---
|
||||
|
||||
// StrataRepo is where Strata is published: its source, and in each release
|
||||
// the prebuilt engine for each CUDA.
|
||||
const StrataRepo = "Niko1221/Strata"
|
||||
|
||||
// strataReleases are the Strata versions this agent installs: the source
|
||||
// archive's digest, taken when it was added here and confirmed from a second
|
||||
// machine, when the version was published, and the engine its release
|
||||
// carries, whose files GitHub publishes digests for.
|
||||
var strataReleases = map[string]struct {
|
||||
sha256 string
|
||||
published string
|
||||
engine string
|
||||
}{
|
||||
"0.2.7": {"41ae861478f5e5d9506864f97dfb5dd7cfe6b83193300e2b0d4f04c0380e3af9", "2026-10-27T14:54:42Z", "0.1.52"},
|
||||
}
|
||||
|
||||
// strataCandidates are the Strata builds for a machine: its source, in a
|
||||
// Python environment, with the prebuilt engine for the node's driver and
|
||||
// cards — built for CUDA 13, Turing to Blackwell; or for CUDA 12.9, Pascal
|
||||
// to Ada, which is what a V100, or a driver older than 580, gets.
|
||||
func strataCandidates(version, variant string, h discovery.Host) ([]Build, error) {
|
||||
if _, err := gpuArch("Strata", h); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if h.Arch != "amd64" {
|
||||
return nil, NotHere{"Strata publishes its Linux CUDA engine for x86-64 only"}
|
||||
}
|
||||
rel, ok := strataReleases[version]
|
||||
if !ok {
|
||||
known := make([]string, 0, len(strataReleases))
|
||||
for v := range strataReleases {
|
||||
known = append(known, v)
|
||||
}
|
||||
slices.Sort(known)
|
||||
return nil, fmt.Errorf("this agent knows the source digest of Strata %s, not of %s; "+
|
||||
"install that one by hand and name it in agent.yaml (engines.strata.dir)",
|
||||
strings.Join(known, ", "), version)
|
||||
}
|
||||
published, err := time.Parse(time.RFC3339, rel.published)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
build := func(cuda string) Build {
|
||||
return Build{
|
||||
Variant: cuda,
|
||||
Python: pythonForEngines,
|
||||
Source: &Source{
|
||||
URL: "/" + StrataRepo + "/archive/refs/tags/v" + version + ".tar.gz",
|
||||
SHA256: rel.sha256, Top: "Strata-" + version, Dir: "strata",
|
||||
Requirements: "requirements.txt",
|
||||
},
|
||||
AsOf: published.Add(24 * time.Hour),
|
||||
Assets: []string{"strata-engine-" + rel.engine + "-linux-x86_64-cuda-" + cuda + ".tar.gz"},
|
||||
AssetTag: "v" + version,
|
||||
AssetDir: "strata-engine",
|
||||
// Strata is started as a script, so what the agent is given is
|
||||
// the environment's interpreter; the proof is importing what its
|
||||
// server needs, and finding its engine beside it.
|
||||
Binary: "python",
|
||||
VersionArgs: []string{"-c", `import os, sys, numpy, jinja2, regex
|
||||
e = os.path.dirname(os.path.dirname(os.path.abspath(sys.executable)))
|
||||
for f in ("strata/setup.py", "strata/serve/server.py", "strata-engine/strata"):
|
||||
assert os.path.isfile(os.path.join(e, f)), f + " is missing"
|
||||
print("strata version", "` + version + `", "engine", "` + rel.engine + `")`},
|
||||
FreeGB: 5,
|
||||
}
|
||||
}
|
||||
return pick("Strata "+version, variant, []candidate{
|
||||
{build("cuda13"), gpuNeeds{minDriverForCUDA13, 7.5}, "Strata " + version + " built for CUDA 13"},
|
||||
// 12.9 rather than 12: its machine code is for 6.0 to 8.9 and its
|
||||
// PTX was compiled by 12.9, so the driver must be 575.
|
||||
{build("cuda12"), gpuNeeds{575, 6.0}, "Strata " + version + " built for CUDA 12.9"},
|
||||
}, h)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package provision
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
|
||||
// strataRelease is Strata's source archive as GitHub makes one, and its
|
||||
// release's engine for CUDA 13, with the recipe pointed at the archive.
|
||||
func strataRelease(t *testing.T, rel *release) {
|
||||
t.Helper()
|
||||
archive := tarGz(t, map[string]string{
|
||||
"Strata-0.2.7/setup.py": "print('setup')\n",
|
||||
"Strata-0.2.7/serve/server.py": "print('server')\n",
|
||||
"Strata-0.2.7/requirements.txt": "numpy==2.5.3; python_version >= \"3.12\"\njinja2==3.1.6\n",
|
||||
}, nil)
|
||||
rel.assets["v0.2.7.tar.gz"] = archive
|
||||
sum := sha256.Sum256(archive)
|
||||
engine := tarGz(t, map[string]string{"strata": "\x7fELF", "libcudart.so.13": "lib"}, nil)
|
||||
name := "strata-engine-0.1.52-linux-x86_64-cuda-cuda13.tar.gz"
|
||||
rel.assets[name] = engine
|
||||
esum := sha256.Sum256(engine)
|
||||
rel.digests[name] = "sha256:" + hex.EncodeToString(esum[:])
|
||||
|
||||
old := strataReleases
|
||||
strataReleases = map[string]struct {
|
||||
sha256 string
|
||||
published string
|
||||
engine string
|
||||
}{"0.2.7": {hex.EncodeToString(sum[:]), "2026-10-27T14:54:42Z", "0.1.52"}}
|
||||
t.Cleanup(func() { strataReleases = old })
|
||||
}
|
||||
|
||||
func TestStrataIsInstalledWithItsEngineBesideItsCode(t *testing.T) {
|
||||
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||
in, rel := pythonInstaller(t, turingNode, sys)
|
||||
strataRelease(t, rel)
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "strata", Version: "0.2.7"}})
|
||||
if got[0].State != Ready {
|
||||
t.Fatalf("not ready: %+v", got[0])
|
||||
}
|
||||
dir := filepath.Join(in.Root, "strata", "0.2.7-cuda13")
|
||||
if got[0].Binary != filepath.Join(dir, "bin", "python") {
|
||||
t.Errorf("binary %s, want the environment's interpreter", got[0].Binary)
|
||||
}
|
||||
for _, f := range []string{"strata/setup.py", "strata/serve/server.py", "strata-engine/strata", "strata-engine/libcudart.so.13"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, f)); err != nil {
|
||||
t.Errorf("%s is not in the environment: %v", f, err)
|
||||
}
|
||||
}
|
||||
all := strings.Join(sys.commands, "\n")
|
||||
for _, want := range []string{"--exclude-newer 2026-10-28T14:54:42Z", "jinja2==3.1.6"} {
|
||||
if !strings.Contains(all, want) {
|
||||
t.Errorf("the installation never used %q:\n%s", want, all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataRefusesAnEngineWhoseDigestDoesNotMatch(t *testing.T) {
|
||||
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
|
||||
in, rel := pythonInstaller(t, turingNode, sys)
|
||||
strataRelease(t, rel)
|
||||
rel.digests["strata-engine-0.1.52-linux-x86_64-cuda-cuda13.tar.gz"] = "sha256:" + strings.Repeat("0", 64)
|
||||
|
||||
got := in.Converge(t.Context(), []Want{{Engine: "strata", Version: "0.2.7", Variant: "cuda13"}})
|
||||
if got[0].State != Failed || !strings.Contains(got[0].Detail, "does not match the checksum") {
|
||||
t.Fatalf("an engine that does not match its digest was not refused: %+v", got[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataPicksTheEngineForTheCardsAndDriver(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
host discovery.Host
|
||||
want []string
|
||||
err string
|
||||
}{
|
||||
{"turing on 580", turingNode, []string{"cuda13", "cuda12"}, ""},
|
||||
{"volta", discovery.Host{Arch: "amd64", NVIDIADriver: "580.82.07", MinComputeCapability: 7.0}, []string{"cuda12"}, ""},
|
||||
{"driver 575", discovery.Host{Arch: "amd64", NVIDIADriver: "575.51.03", MinComputeCapability: 8.6}, []string{"cuda12"}, ""},
|
||||
{"driver 570", discovery.Host{Arch: "amd64", NVIDIADriver: "570.86.10", MinComputeCapability: 8.6}, nil, "575"},
|
||||
{"no gpu", discovery.Host{Arch: "amd64"}, nil, "no"},
|
||||
{"arm", discovery.Host{Arch: "arm64", NVIDIADriver: "580.82.07", MinComputeCapability: 9.0}, nil, "x86-64"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
bs, err := strataCandidates("0.2.7", "", tc.host)
|
||||
if tc.err != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), tc.err) {
|
||||
t.Fatalf("got %v, %v; want an error saying %q", bs, err, tc.err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []string
|
||||
for _, b := range bs {
|
||||
got = append(got, b.Variant)
|
||||
}
|
||||
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
|
||||
t.Errorf("builds %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStrataIsNotInAnOfflineBundle(t *testing.T) {
|
||||
if _, err := Builds("strata", "0.2.7", "amd64"); err == nil {
|
||||
t.Fatal("an offline bundle would carry Strata, which needs PyPI")
|
||||
}
|
||||
}
|
||||
@@ -90,14 +90,20 @@ var ErrNotLocal = errors.New("the weights are not on this node and the source sa
|
||||
// already cached, downloaded, or downloaded by another shard this one waited
|
||||
// for — since that is most of the difference between a replica that starts
|
||||
// in a minute and one that takes an hour.
|
||||
func (f *Fetcher) Ensure(ctx context.Context, source string) (path string, err error) {
|
||||
//
|
||||
// A source in a pickle format, or a set with such a file in it, is refused
|
||||
// before anything is fetched unless allowPickle; see Pickled.
|
||||
func (f *Fetcher) Ensure(ctx context.Context, source string, allowPickle bool) (path string, err error) {
|
||||
ctx, span := obs.Tracer(tracerName).Start(ctx, "fetch weights",
|
||||
trace.WithAttributes(attribute.String("llmgw.weights.source", redact(source))))
|
||||
defer func() {
|
||||
span.SetAttributes(attribute.String("llmgw.weights.path", path))
|
||||
obs.End(span, err)
|
||||
}()
|
||||
return f.ensure(ctx, source)
|
||||
if !allowPickle && !IsSet(source) && Pickled(source) {
|
||||
return "", refusePickle(source)
|
||||
}
|
||||
return f.ensure(ctx, source, allowPickle)
|
||||
}
|
||||
|
||||
// tracerName names the spans this package opens.
|
||||
@@ -119,7 +125,7 @@ func redact(source string) string {
|
||||
return u.String()
|
||||
}
|
||||
|
||||
func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) {
|
||||
func (f *Fetcher) ensure(ctx context.Context, source string, allowPickle bool) (string, error) {
|
||||
if source == "" {
|
||||
return "", fmt.Errorf("no source given")
|
||||
}
|
||||
@@ -127,7 +133,7 @@ func (f *Fetcher) ensure(ctx context.Context, source string) (string, error) {
|
||||
// file, and a set has to be handled before any of it.
|
||||
if IsSet(source) {
|
||||
outcome(ctx, "set")
|
||||
return f.ensureSet(ctx, source)
|
||||
return f.ensureSet(ctx, source, allowPickle)
|
||||
}
|
||||
if !isRemote(source) {
|
||||
if !isPath(source) {
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestALocalPathIsUsedNeverCopied(t *testing.T) {
|
||||
}
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
got, err := f.Ensure(t.Context(), path)
|
||||
got, err := f.Ensure(t.Context(), path, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func TestAMissingLocalPathSaysWhatIsWrong(t *testing.T) {
|
||||
// The commonest setup mistake, and "no such file" from an engine twenty
|
||||
// minutes into a load is a poor way to learn it.
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
_, err := f.Ensure(t.Context(), filepath.Join(t.TempDir(), "absent.gguf"))
|
||||
_, err := f.Ensure(t.Context(), filepath.Join(t.TempDir(), "absent.gguf"), false)
|
||||
if !errors.Is(err, ErrNotLocal) {
|
||||
t.Fatalf("err = %v, want ErrNotLocal", err)
|
||||
}
|
||||
@@ -67,7 +67,7 @@ func TestARemoteFileIsFetchedOnceAndReused(t *testing.T) {
|
||||
srv := serveBytes(t, []byte("the weights"), &hits)
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
first, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
first, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func TestARemoteFileIsFetchedOnceAndReused(t *testing.T) {
|
||||
t.Fatalf("fetched content = %q, err %v", body, err)
|
||||
}
|
||||
|
||||
second, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
second, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -103,7 +103,7 @@ func TestConcurrentRequestsProduceOneDownload(t *testing.T) {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
paths[i], errs[i] = f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
paths[i], errs[i] = f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -139,7 +139,7 @@ func TestAPartialDownloadIsNeverVisibleAsTheModel(t *testing.T) {
|
||||
|
||||
cache := t.TempDir()
|
||||
f := &Fetcher{CacheDir: cache}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf"); err == nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false); err == nil {
|
||||
t.Fatal("a truncated download was reported as success")
|
||||
}
|
||||
entries, _ := os.ReadDir(cache)
|
||||
@@ -169,7 +169,7 @@ func TestAnInterruptedDownloadResumes(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/model.gguf")
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -232,7 +232,7 @@ func TestProgressIsReportedWithAPercentage(t *testing.T) {
|
||||
CacheDir: t.TempDir(),
|
||||
OnProgress: func(p Progress) { seen = append(seen, p) },
|
||||
}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf"); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.gguf", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(seen) == 0 {
|
||||
@@ -254,7 +254,7 @@ func TestPercentIsUnknownRatherThanZeroWithoutATotal(t *testing.T) {
|
||||
|
||||
func TestNoCacheDirectoryIsAClearRefusal(t *testing.T) {
|
||||
f := &Fetcher{}
|
||||
_, err := f.Ensure(t.Context(), "https://example.invalid/model.gguf")
|
||||
_, err := f.Ensure(t.Context(), "https://example.invalid/model.gguf", false)
|
||||
if err == nil || !strings.Contains(err.Error(), "cache directory") {
|
||||
t.Errorf("err = %v, want a complaint about the cache directory", err)
|
||||
}
|
||||
@@ -269,7 +269,7 @@ func TestABareModelIdIsLeftToTheEngine(t *testing.T) {
|
||||
"Qwen/Qwen2.5-7B-Instruct",
|
||||
"meta-llama/Llama-3.3-70B-Instruct",
|
||||
} {
|
||||
got, err := f.Ensure(t.Context(), id)
|
||||
got, err := f.Ensure(t.Context(), id, false)
|
||||
if err != nil {
|
||||
t.Errorf("%s was refused: %v", id, err)
|
||||
}
|
||||
@@ -283,7 +283,7 @@ func TestAnAbsolutePathThatIsMissingIsStillAnError(t *testing.T) {
|
||||
// The other half: an operator writing a path means a file, and a missing
|
||||
// one should be said plainly rather than handed to an engine.
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
if _, err := f.Ensure(t.Context(), "/models/definitely-not-here.gguf"); !errors.Is(err, ErrNotLocal) {
|
||||
if _, err := f.Ensure(t.Context(), "/models/definitely-not-here.gguf", false); !errors.Is(err, ErrNotLocal) {
|
||||
t.Errorf("err = %v, want ErrNotLocal", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package weights
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Weights in Python's pickle format are a program as much as a file: loading
|
||||
// one runs whatever it says, as the engine's user, on the node. That is how
|
||||
// the malicious models found on Hugging Face work, and nothing in the file's
|
||||
// name or size says so. safetensors and GGUF are only tensors and are read,
|
||||
// never run, so those are what a node fetches unless the catalog says
|
||||
// otherwise for a model, with allow_pickle, on purpose.
|
||||
|
||||
// pickleExts are the extensions of PyTorch's pickle formats, and of pickle
|
||||
// itself. A .bin beside safetensors is the same model in pickle form.
|
||||
var pickleExts = []string{".bin", ".pt", ".pt2", ".pth", ".ckpt", ".pkl", ".pickle"}
|
||||
|
||||
// Pickled reports whether a file name or URL is in a pickle format.
|
||||
func Pickled(name string) bool {
|
||||
// A query or a fragment is not the file's name, on a URL or an hf:
|
||||
// source alike: model.bin?download=true is still model.bin.
|
||||
if i := strings.IndexAny(name, "?#"); i >= 0 {
|
||||
name = name[:i]
|
||||
}
|
||||
ext := strings.ToLower(path.Ext(name))
|
||||
for _, e := range pickleExts {
|
||||
if ext == e {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ErrPickle is a source refused for being in a pickle format.
|
||||
var ErrPickle = errors.New("weights in a pickle format run code when they are loaded")
|
||||
|
||||
func refusePickle(name string) error {
|
||||
return fmt.Errorf("%w: %s is refused; use the model's safetensors or GGUF, or set allow_pickle: true "+
|
||||
"on the model if it was checked and has no other form", ErrPickle, redact(name))
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package weights
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPickleFormatsAreKnownByTheirNames(t *testing.T) {
|
||||
for name, want := range map[string]bool{
|
||||
"pytorch_model.bin": true, "model.ckpt": true, "x.PT": true, "w.pth": true, "a.pkl": true,
|
||||
"https://h/r/resolve/main/model.ckpt?download=true": true,
|
||||
"hf:o/r/model.bin?download=true": true, "hf:o/r/model.ckpt#x": true, "m.pt2": true,
|
||||
"model.safetensors": false, "m-Q4_K_M.gguf": false, "Qwen/Qwen2.5-7B-Instruct": false,
|
||||
"config.json": false, "": false,
|
||||
} {
|
||||
if got := Pickled(name); got != want {
|
||||
t.Errorf("Pickled(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPickleFileIsRefusedBeforeItIsFetched(t *testing.T) {
|
||||
var hits atomic.Int64
|
||||
srv := serveBytes(t, []byte("not really a pickle"), &hits)
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/model.ckpt", false)
|
||||
if !errors.Is(err, ErrPickle) {
|
||||
t.Fatalf("a .ckpt was not refused: %v", err)
|
||||
}
|
||||
if hits.Load() != 0 {
|
||||
t.Errorf("the refused file was fetched %d times", hits.Load())
|
||||
}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/model.ckpt", true); err != nil {
|
||||
t.Errorf("allowed, the .ckpt was still refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASetWithAPickleFileIsRefusedWhole(t *testing.T) {
|
||||
f, srv, hits := mirrorOf(t, map[string]string{
|
||||
"pytorch_model.bin": "pickle",
|
||||
"config.json": "{}",
|
||||
})
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); !errors.Is(err, ErrPickle) {
|
||||
t.Fatalf("a set holding pytorch_model.bin was not refused: %v", err)
|
||||
}
|
||||
if *hits != 0 {
|
||||
t.Errorf("%d files of the refused set were fetched", *hits)
|
||||
}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", true); err != nil {
|
||||
t.Errorf("allowed, the set was still refused: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASetAlreadyHereIsLookedAtAgain(t *testing.T) {
|
||||
// Fetched once for a model that allowed pickles, or before they were
|
||||
// refused: another asking for it without leave is refused all the same.
|
||||
f, srv, _ := mirrorOf(t, map[string]string{"pytorch_model.bin": "pickle", "config.json": "{}"})
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); !errors.Is(err, ErrPickle) {
|
||||
t.Errorf("a set already on the node was not looked at: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ type manifest struct {
|
||||
// The whole set or nothing. A directory with three of four shards in it loads
|
||||
// as a broken model rather than failing to load, so a partial set is left
|
||||
// unfinished under its temporary name and the next attempt starts again.
|
||||
func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error) {
|
||||
func (f *Fetcher) ensureSet(ctx context.Context, source string, allowPickle bool) (string, error) {
|
||||
dir, err := f.cachePath(strings.TrimSuffix(source, "/"))
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -56,6 +56,16 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error)
|
||||
// exactly the failure the marker exists to prevent.
|
||||
done := filepath.Join(dir, ".complete")
|
||||
if _, err := os.Stat(done); err == nil {
|
||||
// Already here, maybe fetched for a model that allowed pickles, or
|
||||
// before they were refused: looked at again, not trusted.
|
||||
if !allowPickle {
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if Pickled(e.Name()) {
|
||||
return "", refusePickle(source + e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
@@ -66,6 +76,15 @@ func (f *Fetcher) ensureSet(ctx context.Context, source string) (string, error)
|
||||
if len(m.Files) == 0 {
|
||||
return "", fmt.Errorf("%s holds no files", source)
|
||||
}
|
||||
// Refused whole rather than skipped: an engine given the directory
|
||||
// loads the pickle when there is no safetensors beside it.
|
||||
if !allowPickle {
|
||||
for _, file := range m.Files {
|
||||
if Pickled(file.Name) {
|
||||
return "", refusePickle(source + file.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -53,7 +53,7 @@ func TestASetIsFetchedIntoOneDirectory(t *testing.T) {
|
||||
"config.json": "{}",
|
||||
})
|
||||
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -74,11 +74,11 @@ func TestASetIsFetchedIntoOneDirectory(t *testing.T) {
|
||||
func TestAFinishedSetIsNotFetchedAgain(t *testing.T) {
|
||||
f, srv, hits := mirrorOf(t, map[string]string{"a.gguf": "aaaa", "b.gguf": "bb"})
|
||||
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after := *hits
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if *hits != after {
|
||||
@@ -100,7 +100,7 @@ func TestAHalfFetchedSetIsNotTreatedAsFinished(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
got, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func TestAFileAlreadyThereAtTheRightSizeIsNotRefetched(t *testing.T) {
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
_ = os.WriteFile(filepath.Join(dir, "a.gguf"), []byte("aaaa"), 0o644)
|
||||
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Resuming a set means not paying for the shards that already landed.
|
||||
@@ -133,7 +133,7 @@ func TestAFilenameThatEscapesTheDirectoryIsRefused(t *testing.T) {
|
||||
defer srv.Close()
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||
// The mirror is a place an operator copies files into by hand, so what it
|
||||
// reports is not automatically safe to join onto a path.
|
||||
if err == nil || !strings.Contains(err.Error(), "offered a file named") {
|
||||
@@ -148,7 +148,7 @@ func TestADirectoryListingIsNotAManifest(t *testing.T) {
|
||||
defer srv.Close()
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||
// "invalid character '<'" sends somebody looking at the wrong thing.
|
||||
if err == nil || !strings.Contains(err.Error(), "has to be a mirror version") {
|
||||
t.Errorf("the reason should name the cause, got %v", err)
|
||||
@@ -182,12 +182,12 @@ func TestASetFileThatDoesNotMatchTheMirrorsChecksumIsRefused(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err == nil || !strings.Contains(err.Error(), "sha256") {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err == nil || !strings.Contains(err.Error(), "sha256") {
|
||||
t.Fatalf("a damaged shard was accepted: %v", err)
|
||||
}
|
||||
served = good
|
||||
// The size differs, so it is fetched again; this time it matches.
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/"); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false); err != nil {
|
||||
t.Fatalf("the shard as mirrored: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -198,7 +198,7 @@ func TestASetStillDownloadingOnTheMirrorSaysSo(t *testing.T) {
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/")
|
||||
_, err := f.Ensure(t.Context(), srv.URL+"/m/v/", false)
|
||||
if err == nil || !strings.Contains(err.Error(), "not complete on the mirror yet") ||
|
||||
!strings.Contains(err.Error(), "model-00002-of-00002") {
|
||||
t.Fatalf("got %v", err)
|
||||
|
||||
@@ -13,7 +13,7 @@ func TestAFetchSaysInItsTraceWhetherItDownloadedAndHowMuch(t *testing.T) {
|
||||
f := &Fetcher{CacheDir: t.TempDir()}
|
||||
src := strings.Replace(srv.URL, "http://", "http://user:secret@", 1) + "/m/model.gguf?token=hunter2"
|
||||
|
||||
if _, err := f.Ensure(t.Context(), src); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), src, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fetch := rec.One(t, [16]byte{}, "fetch weights")
|
||||
@@ -30,7 +30,7 @@ func TestAFetchSaysInItsTraceWhetherItDownloadedAndHowMuch(t *testing.T) {
|
||||
}
|
||||
|
||||
// The second start on this node finds it here.
|
||||
if _, err := f.Ensure(t.Context(), src); err != nil {
|
||||
if _, err := f.Ensure(t.Context(), src, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again := rec.Named("fetch weights")
|
||||
|
||||
@@ -5,6 +5,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
@@ -12,6 +14,7 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
project string
|
||||
ttl time.Duration
|
||||
anyAnswer bool
|
||||
similar float64
|
||||
off bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
@@ -28,15 +31,25 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
"a request continuing a stored conversation or under a guardrails no_store\n" +
|
||||
"policy. The guardrails judge a reused answer as they judge any other.\n\n" +
|
||||
"Off for every project until this is run. Without --ttl or --off, says how\n" +
|
||||
"it stands. The answers are held in memory, router.cache bounds how much.",
|
||||
"it stands. The answers are held in memory, router.cache bounds how much.\n\n" +
|
||||
"--similar also answers a chat question that is like one already answered:\n" +
|
||||
"its last user message embedded by router.cache.embedding_model, and given\n" +
|
||||
"the answer whose question reaches that cosine similarity (0.95 is strict,\n" +
|
||||
"0.9 looser), as x-llmgw-cache: similar. The system prompt, the earlier\n" +
|
||||
"turns, tools and the sampling settings must still be the same.",
|
||||
Example: " llmgw keys cache --project Acme/ml/train --ttl 1h\n" +
|
||||
" llmgw keys cache --project Acme/ml/eval --ttl 24h --any\n" +
|
||||
" llmgw keys cache --project Acme/support/desk --ttl 24h --similar 0.95\n" +
|
||||
" llmgw keys cache --project Acme/ml/train --off",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
switch {
|
||||
case off && (ttl != 0 || anyAnswer):
|
||||
return fmt.Errorf("--off, or --ttl with --any: not both")
|
||||
case off && (ttl != 0 || anyAnswer || similar != 0):
|
||||
return fmt.Errorf("--off, or --ttl with --any or --similar: not both")
|
||||
case similar < 0 || similar > 1:
|
||||
return fmt.Errorf("--similar is a cosine similarity between 0 and 1, like 0.95")
|
||||
case similar != 0 && ttl == 0:
|
||||
return fmt.Errorf("--similar goes with --ttl: say how long answers are reused")
|
||||
case ttl < 0:
|
||||
return fmt.Errorf("--ttl is how long an answer is reused, like 1h")
|
||||
case ttl > 0 && ttl < time.Second:
|
||||
@@ -54,7 +67,7 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
if off || ttl > 0 {
|
||||
if err := s.SetAnswerCache(cmd.Context(), p.ID, ttl, anyAnswer); err != nil {
|
||||
if err := s.SetAnswerCache(cmd.Context(), p.ID, store.AnswerCache{TTL: ttl, Any: anyAnswer, Similar: similar}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -71,6 +84,9 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s reuses answers for %s: those asked at temperature 0 or with a seed, "+
|
||||
"and embeddings, rerank and score.\n", p.Path, c.TTL)
|
||||
}
|
||||
if c.TTL > 0 && c.Similar > 0 {
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "Chat questions reaching %.2f similarity to one answered get its answer too.\n", c.Similar)
|
||||
}
|
||||
if off || ttl > 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "Gateways see the change within 30 seconds.")
|
||||
}
|
||||
@@ -80,6 +96,7 @@ func newAnswerCacheCmd(open opener) *cobra.Command {
|
||||
cmd.Flags().StringVar(&project, "project", "", "project: id, path or unique name")
|
||||
cmd.Flags().DurationVar(&ttl, "ttl", 0, "how long an answer is reused, like 1h")
|
||||
cmd.Flags().BoolVar(&anyAnswer, "any", false, "reuse sampled answers too, not only reproducible ones")
|
||||
cmd.Flags().Float64Var(&similar, "similar", 0, "also answer chat questions this similar to one answered, like 0.95")
|
||||
cmd.Flags().BoolVar(&off, "off", false, "stop reusing answers for this project")
|
||||
return cmd
|
||||
}
|
||||
@@ -150,6 +150,7 @@ func newBenchCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
f.DurationVar(&cfg.SLO.TPOTp99, "tpot-p99", cfg.SLO.TPOTp99, "per-output-token target")
|
||||
f.BoolVar(&save, "save", false,
|
||||
"record the capacity against the model and profile, so the number outlives this terminal")
|
||||
cmd.AddCommand(newBenchOverheadCmd(asJSON))
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,421 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/router"
|
||||
storepkg "gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// What llmgw adds to a request, measured: the same load straight at an
|
||||
// engine that answers at once, and through the gateway in front of it —
|
||||
// bare, with authentication and usage accounting, and with the guardrails'
|
||||
// pattern checks besides. The engine's own time is the same on both paths,
|
||||
// so what differs is the gateway's.
|
||||
//
|
||||
// Everything runs in this process, on loopback: no cluster, no GPU, and the
|
||||
// numbers are this machine's. On a machine shared with other work (a VM on
|
||||
// an oversubscribed host) they move with it; compare modes run together,
|
||||
// not figures from different runs.
|
||||
|
||||
// overheadMode is one way a request reaches the engine.
|
||||
type overheadMode struct {
|
||||
name string
|
||||
gateway bool
|
||||
auth, guard bool
|
||||
}
|
||||
|
||||
var overheadModes = []overheadMode{
|
||||
{name: "direct"},
|
||||
{name: "gateway", gateway: true},
|
||||
{name: "gateway+auth", gateway: true, auth: true},
|
||||
{name: "gateway+auth+guard", gateway: true, auth: true, guard: true},
|
||||
}
|
||||
|
||||
// overheadStep is one mode at one request rate.
|
||||
type overheadStep struct {
|
||||
Mode string `json:"mode"`
|
||||
Rate int `json:"rate"`
|
||||
Sent int `json:"sent"`
|
||||
Failed int `json:"failed"`
|
||||
P50 time.Duration `json:"p50"`
|
||||
P99 time.Duration `json:"p99"`
|
||||
AddedP50 time.Duration `json:"added_p50"`
|
||||
AddedP99 time.Duration `json:"added_p99"`
|
||||
// CPUPer1k is the process's CPU time per thousand requests beyond what
|
||||
// the direct mode spent at the same rate: the gateway's.
|
||||
CPUPer1k time.Duration `json:"cpu_per_1k"`
|
||||
cpu time.Duration
|
||||
}
|
||||
|
||||
func newBenchOverheadCmd(asJSON *bool) *cobra.Command {
|
||||
var (
|
||||
rates []int
|
||||
each time.Duration
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "overhead",
|
||||
Short: "Measure what the gateway adds to a request, against an engine that answers at once",
|
||||
Long: "Runs an engine that answers at once and a gateway in front of it, in this\n" +
|
||||
"process on loopback, and sends the same load at each rate straight to the\n" +
|
||||
"engine and through the gateway: bare, with authentication and usage\n" +
|
||||
"accounting, and with the guardrails' pattern checks too. Reports each\n" +
|
||||
"request's latency at p50 and p99, what the gateway added to it, and the\n" +
|
||||
"gateway's CPU time per thousand requests.\n\n" +
|
||||
"Needs no cluster. The figures are this machine's: on one shared with other\n" +
|
||||
"work they move with it, so compare the modes of one run, and note the\n" +
|
||||
"machine's load beside any figure you keep.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
steps, err := runOverhead(cmd.Context(), rates, each)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(steps)
|
||||
}
|
||||
printOverhead(cmd.OutOrStdout(), steps)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.IntSliceVar(&rates, "rates", []int{100, 500, 1000, 2000}, "requests a second to send, one step each")
|
||||
f.DurationVar(&each, "each", 3*time.Second, "how long each step sends for")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func printOverhead(w io.Writer, steps []overheadStep) {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "MODE\tRATE/S\tSENT\tFAILED\tP50\tP99\t+P50\t+P99\tCPU/1K REQ")
|
||||
for _, s := range steps {
|
||||
added := func(d time.Duration) string {
|
||||
if s.Mode == "direct" {
|
||||
return "-"
|
||||
}
|
||||
return msOf(d)
|
||||
}
|
||||
cpu := "-"
|
||||
if s.Mode != "direct" {
|
||||
cpu = msOf(s.CPUPer1k)
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%d\t%d\t%d\t%s\t%s\t%s\t%s\t%s\n", s.Mode, s.Rate, s.Sent, s.Failed,
|
||||
msOf(s.P50), msOf(s.P99), added(s.AddedP50), added(s.AddedP99), cpu)
|
||||
}
|
||||
_ = tw.Flush()
|
||||
fmt.Fprintf(w, "\n%d CPUs, %s. Loopback, in one process: the figures are this machine's.\n",
|
||||
runtime.NumCPU(), loadAverage())
|
||||
}
|
||||
|
||||
func msOf(d time.Duration) string { return fmt.Sprintf("%.2fms", float64(d)/float64(time.Millisecond)) }
|
||||
|
||||
// loadAverage is the machine's, where it can be read, to keep beside the
|
||||
// figures.
|
||||
func loadAverage() string {
|
||||
if b, err := os.ReadFile("/proc/loadavg"); err == nil {
|
||||
if f := strings.Fields(string(b)); len(f) >= 3 {
|
||||
return "load " + strings.Join(f[:3], " ")
|
||||
}
|
||||
}
|
||||
// macOS: "{ 1.23 1.10 0.98 }".
|
||||
if b, err := exec.Command("sysctl", "-n", "vm.loadavg").Output(); err == nil {
|
||||
if f := strings.Fields(strings.Trim(strings.TrimSpace(string(b)), "{}")); len(f) >= 3 {
|
||||
return "load " + strings.Join(f[:3], " ")
|
||||
}
|
||||
}
|
||||
return "load not read"
|
||||
}
|
||||
|
||||
// runOverhead measures every mode at every rate.
|
||||
func runOverhead(ctx context.Context, rates []int, each time.Duration) ([]overheadStep, error) {
|
||||
engine, err := instantEngine()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer engine.Close()
|
||||
dir, err := os.MkdirTemp("", "llmgw-overhead-")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
type target struct {
|
||||
url, key string
|
||||
stop func()
|
||||
}
|
||||
targets := map[string]target{}
|
||||
defer func() {
|
||||
for _, t := range targets {
|
||||
if t.stop != nil {
|
||||
t.stop()
|
||||
}
|
||||
}
|
||||
}()
|
||||
for _, m := range overheadModes {
|
||||
if !m.gateway {
|
||||
targets[m.name] = target{url: engine.URL}
|
||||
continue
|
||||
}
|
||||
url, key, stop, err := overheadGateway(ctx, engine.URL, filepath.Join(dir, m.name+".db"), m)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", m.name, err)
|
||||
}
|
||||
targets[m.name] = target{url: url, key: key, stop: stop}
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: &http.Transport{MaxIdleConns: 1024, MaxIdleConnsPerHost: 1024}, Timeout: 30 * time.Second}
|
||||
var out []overheadStep
|
||||
for _, rate := range rates {
|
||||
var direct overheadStep
|
||||
for _, m := range overheadModes {
|
||||
t := targets[m.name]
|
||||
s := loadStep(ctx, client, t.url, t.key, rate, each)
|
||||
s.Mode = m.name
|
||||
if m.name == "direct" {
|
||||
direct = s
|
||||
} else {
|
||||
s.AddedP50, s.AddedP99 = max(0, s.P50-direct.P50), max(0, s.P99-direct.P99)
|
||||
if s.Sent > 0 {
|
||||
s.CPUPer1k = max(0, s.cpu-direct.cpu) * 1000 / time.Duration(s.Sent)
|
||||
}
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadStep sends rate requests a second for d, each in its own goroutine,
|
||||
// and measures each one's latency and the process's CPU meanwhile.
|
||||
func loadStep(ctx context.Context, client *http.Client, url, key string, rate int, d time.Duration) overheadStep {
|
||||
s := overheadStep{Rate: rate}
|
||||
body := []byte(`{"model":"bench","max_tokens":8,"messages":[{"role":"user","content":"What is the capital of France?"}]}`)
|
||||
var (
|
||||
mu sync.Mutex
|
||||
lat []time.Duration
|
||||
wg sync.WaitGroup
|
||||
total = int(float64(rate) * d.Seconds())
|
||||
)
|
||||
interval := time.Second / time.Duration(max(rate, 1))
|
||||
cpu0 := processCPU()
|
||||
start := time.Now()
|
||||
for i := range total {
|
||||
if ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
if wait := time.Until(start.Add(time.Duration(i) * interval)); wait > 0 {
|
||||
time.Sleep(wait)
|
||||
}
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url+"/v1/chat/completions", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
t := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
ok := err == nil && resp.StatusCode == http.StatusOK
|
||||
if resp != nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
}
|
||||
el := time.Since(t)
|
||||
mu.Lock()
|
||||
s.Sent++
|
||||
if ok {
|
||||
lat = append(lat, el)
|
||||
} else {
|
||||
s.Failed++
|
||||
}
|
||||
mu.Unlock()
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
s.cpu = processCPU() - cpu0
|
||||
s.P50, s.P99 = percentile(lat, 0.50), percentile(lat, 0.99)
|
||||
return s
|
||||
}
|
||||
|
||||
func percentile(ds []time.Duration, p float64) time.Duration {
|
||||
if len(ds) == 0 {
|
||||
return 0
|
||||
}
|
||||
ds = slices.Clone(ds)
|
||||
sort.Slice(ds, func(i, j int) bool { return ds[i] < ds[j] })
|
||||
i := int(p * float64(len(ds)-1))
|
||||
return ds[i]
|
||||
}
|
||||
|
||||
// processCPU is the CPU time this process has used, user and system.
|
||||
func processCPU() time.Duration {
|
||||
var ru syscall.Rusage
|
||||
if err := syscall.Getrusage(syscall.RUSAGE_SELF, &ru); err != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(ru.Utime.Nano() + ru.Stime.Nano())
|
||||
}
|
||||
|
||||
// instantEngine is an OpenAI-compatible engine that answers at once, with
|
||||
// the metrics the router scrapes.
|
||||
func instantEngine() (*httpServer, error) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /metrics", func(w http.ResponseWriter, _ *http.Request) {
|
||||
fmt.Fprint(w, "# TYPE vllm:num_requests_running gauge\nvllm:num_requests_running 0\n"+
|
||||
"# TYPE vllm:num_requests_waiting gauge\nvllm:num_requests_waiting 0\n"+
|
||||
"# TYPE vllm:kv_cache_usage_perc gauge\nvllm:kv_cache_usage_perc 0.1\n")
|
||||
})
|
||||
mux.HandleFunc("GET /health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
mux.HandleFunc("POST /v1/chat/completions", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, r.Body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprint(w, `{"id":"b","object":"chat.completion","model":"bench","choices":[{"index":0,"message":{"role":"assistant","content":"Paris."},"finish_reason":"stop"}],"usage":{"prompt_tokens":14,"completion_tokens":2,"total_tokens":16}}`)
|
||||
})
|
||||
return serveLoopback(mux)
|
||||
}
|
||||
|
||||
// httpServer is a handler served on a loopback port.
|
||||
type httpServer struct {
|
||||
URL string
|
||||
srv *http.Server
|
||||
}
|
||||
|
||||
func (s *httpServer) Close() { _ = s.srv.Close() }
|
||||
|
||||
func serveLoopback(h http.Handler) (*httpServer, error) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srv := &http.Server{Handler: h, ReadHeaderTimeout: 10 * time.Second}
|
||||
go func() { _ = srv.Serve(ln) }()
|
||||
return &httpServer{URL: "http://" + ln.Addr().String(), srv: srv}, nil
|
||||
}
|
||||
|
||||
// overheadGateway starts a gateway in front of the engine as the mode asks,
|
||||
// and returns its address and, with auth, a key.
|
||||
func overheadGateway(ctx context.Context, engineURL, storePath string, m overheadMode) (string, string, func(), error) {
|
||||
cfg := config.Default()
|
||||
cfg.Listen = "127.0.0.1:0"
|
||||
cfg.Router.ScrapeInterval = 200 * time.Millisecond
|
||||
cfg.Endpoints = []config.Endpoint{{ID: "bench", URL: engineURL, Models: []string{"bench"}}}
|
||||
key := ""
|
||||
if m.auth {
|
||||
var err error
|
||||
if key, err = overheadKey(ctx, storePath); err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
cfg.Auth.Enabled, cfg.Auth.Store = true, storePath
|
||||
// Limits would measure themselves refusing: off.
|
||||
cfg.Auth.DefaultRPM, cfg.Auth.DefaultTPM = 0, 0
|
||||
}
|
||||
rt, err := router.New(cfg, slog.New(slog.DiscardHandler))
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
if m.guard {
|
||||
g, err := baselineGuard()
|
||||
if err != nil {
|
||||
return "", "", nil, err
|
||||
}
|
||||
rt.SetGuard(g)
|
||||
}
|
||||
rctx, cancel := context.WithCancel(ctx)
|
||||
rt.Start(rctx)
|
||||
srv, err := serveLoopback(rt.Handler())
|
||||
if err != nil {
|
||||
cancel()
|
||||
return "", "", nil, err
|
||||
}
|
||||
stop := func() { srv.Close(); cancel(); rt.Stop() }
|
||||
// Ready once a request goes through: the endpoint is scraped first.
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for {
|
||||
body := `{"model":"bench","messages":[{"role":"user","content":"hi"}]}`
|
||||
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+"/v1/chat/completions", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if key != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+key)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err == nil {
|
||||
_, _ = io.Copy(io.Discard, resp.Body)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
return srv.URL, key, stop, nil
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
stop()
|
||||
status := "no answer"
|
||||
if err == nil {
|
||||
status = resp.Status
|
||||
}
|
||||
return "", "", nil, fmt.Errorf("the gateway did not serve within 10s (%s)", status)
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
// overheadKey makes a store with one project and a key in it.
|
||||
func overheadKey(ctx context.Context, path string) (string, error) {
|
||||
st, err := storepkg.Open(ctx, path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer st.Close()
|
||||
org, err := st.CreateOrg(ctx, "bench")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
team, err := st.CreateTeam(ctx, org, "bench")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
project, err := st.CreateProject(ctx, team, "bench")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
secret, k, err := storepkg.NewKey("key-bench", "bench", project)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
k.CreatedAt = time.Now()
|
||||
return secret, st.CreateKey(ctx, k)
|
||||
}
|
||||
|
||||
// baselineGuard is the policy init installs, without its judge: the pattern
|
||||
// checks every request passes.
|
||||
func baselineGuard() (guard.Guard, error) {
|
||||
m, err := config.Parse([]byte(guardSection(false)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg, err := config.FromMap(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return guard.NewGate(cfg.Guard, nil, slog.New(slog.DiscardHandler))
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The short version, so CI sees the gateway's overhead on every change: every
|
||||
// mode answers every request, and what the gateway adds is measured, not
|
||||
// guessed. No bound is asserted on it, as CI's machines vary; the table is
|
||||
// in the test's log.
|
||||
func TestTheGatewaysOverheadIsMeasured(t *testing.T) {
|
||||
steps, err := runOverhead(t.Context(), []int{50}, time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(steps) != len(overheadModes) {
|
||||
t.Fatalf("%d steps for %d modes", len(steps), len(overheadModes))
|
||||
}
|
||||
for _, s := range steps {
|
||||
if s.Sent == 0 || s.Failed > 0 || s.P50 <= 0 || s.P99 < s.P50 {
|
||||
t.Errorf("%s: sent %d failed %d p50 %s p99 %s", s.Mode, s.Sent, s.Failed, s.P50, s.P99)
|
||||
}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
printOverhead(&b, steps)
|
||||
if !strings.Contains(b.String(), "gateway+auth+guard") {
|
||||
t.Errorf("table:\n%s", b.String())
|
||||
}
|
||||
t.Logf("\n%s", b.String())
|
||||
}
|
||||
@@ -186,6 +186,7 @@ func newCtlCmd() *cobra.Command {
|
||||
newCapacityCmd(client, &asJSON),
|
||||
newQuotaCmd(client, &asJSON),
|
||||
newGuardCmd(client, &asJSON),
|
||||
newMCPCmd(client, &asJSON),
|
||||
newKBCmd(client, &asJSON),
|
||||
newTrainCmd(client, &asJSON),
|
||||
newConfigCmd(client, &asJSON),
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
func newMCPCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "mcp",
|
||||
Short: "The company's own MCP servers, served through llmgw's /mcp",
|
||||
Long: "An MCP server registered here — Jira, a file server, a database — is served\n" +
|
||||
"through llmgw's /mcp beside llmgw's own tools, its tools named SERVER.tool.\n" +
|
||||
"Its credentials stay on the control planes: a client gives only its llmgw\n" +
|
||||
"key. A key sees and calls only the tools granted it, as patterns, with\n" +
|
||||
"`llmgw keys limits --tools 'jira.*'` at any level of the tenancy tree;\n" +
|
||||
"none granted anywhere is none. Every call is in `llmgw ctl audit`.",
|
||||
}
|
||||
c.AddCommand(newMCPAddCmd(client, asJSON), newMCPListCmd(client, asJSON), newMCPRemoveCmd(client),
|
||||
newMCPTestCmd(client, asJSON))
|
||||
return c
|
||||
}
|
||||
|
||||
func newMCPAddCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
var req adminapi.MCPServerRequest
|
||||
var headers, refs, secretFiles []string
|
||||
var bearerFile string
|
||||
var disabled bool
|
||||
c := &cobra.Command{
|
||||
Use: "add NAME --url URL",
|
||||
Short: "Register an MCP server, or change one",
|
||||
Example: " llmgw ctl mcp add jira --url https://jira.internal/mcp --bearer-file /root/jira-token\n" +
|
||||
" llmgw ctl mcp add llmgw --url llmgw:admin --description \"this cluster, read only\"\n" +
|
||||
" llmgw ctl mcp add wiki --url https://wiki.internal/mcp --header-ref Authorization=vault:secret/mcp/wiki#token\n" +
|
||||
" llmgw ctl mcp add jira --disabled # off, kept",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var err error
|
||||
if req.Headers, err = pairs(headers, "--header"); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.HeaderRefs, err = pairs(refs, "--header-ref"); err != nil {
|
||||
return err
|
||||
}
|
||||
files, err := pairs(secretFiles, "--secret-file")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A secret read here and kept in the store, sealed: the file
|
||||
// need not be on every control plane, and is not read again.
|
||||
for header, file := range files {
|
||||
b, err := os.ReadFile(file) //nolint:gosec // a file the operator names
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Secrets == nil {
|
||||
req.Secrets = map[string]string{}
|
||||
}
|
||||
req.Secrets[header] = strings.TrimSpace(string(b))
|
||||
}
|
||||
if bearerFile != "" {
|
||||
b, err := os.ReadFile(bearerFile) //nolint:gosec // a file the operator names
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Secrets == nil {
|
||||
req.Secrets = map[string]string{}
|
||||
}
|
||||
req.Secrets["Authorization"] = "Bearer " + strings.TrimSpace(string(b))
|
||||
}
|
||||
if cmd.Flags().Changed("disabled") {
|
||||
on := !disabled
|
||||
req.Enabled = &on
|
||||
}
|
||||
body, _ := json.Marshal(req)
|
||||
var v adminapi.MCPServerView
|
||||
if err := client().put(cmd.Context(), "/api/v1/mcp/servers/"+args[0], body, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
|
||||
}
|
||||
state := "on"
|
||||
if !v.Enabled {
|
||||
state = "off"
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s: %s, %s. Its tools are %s.TOOL in /mcp, to keys granted them "+
|
||||
"(llmgw keys limits --tools '%s.*'). Check it: llmgw ctl mcp test %s\n", v.Name, v.URL, state, v.Name, v.Name, v.Name)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
f := c.Flags()
|
||||
f.StringVar(&req.URL, "url", "", "the server's streamable HTTP address, https://…/mcp; llmgw:admin for this cluster, read only")
|
||||
f.StringVar(&req.Description, "description", "", "what it is, shown beside its tools")
|
||||
f.StringArrayVar(&headers, "header", nil, "NAME=VALUE sent as written, for what is not secret; repeatable")
|
||||
f.StringArrayVar(&refs, "header-ref", nil, "NAME=REF read at each call: a file on every control plane, env:, vault: or store:; repeatable")
|
||||
f.StringArrayVar(&secretFiles, "secret-file", nil, "NAME=FILE read now and kept in the store, sealed: a token; repeatable")
|
||||
f.StringVar(&bearerFile, "bearer-file", "", "a token FILE, read now and kept sealed, sent as Authorization: Bearer")
|
||||
f.BoolVar(&disabled, "disabled", false, "keep it but serve none of its tools")
|
||||
f.IntVar(&req.TimeoutSeconds, "timeout", 0, "seconds one call may take (default 60)")
|
||||
f.IntVar(&req.RatePerMinute, "rate", 0, "calls a minute each caller may make to its tools (default: no bound; -1 takes one away)")
|
||||
return c
|
||||
}
|
||||
|
||||
func newMCPListCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "The MCP servers /mcp serves",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
var list []adminapi.MCPServerView
|
||||
if err := client().get(cmd.Context(), "/api/v1/mcp/servers", &list); err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(list)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), "no MCP servers; add one: llmgw ctl mcp add NAME --url URL")
|
||||
return nil
|
||||
}
|
||||
w := tabTo(cmd.OutOrStdout())
|
||||
fmt.Fprintln(w, "NAME\tSTATE\tURL\tCREDENTIALS\tDESCRIPTION")
|
||||
for _, s := range list {
|
||||
state := "on"
|
||||
if !s.Enabled {
|
||||
state = "off"
|
||||
}
|
||||
var creds []string
|
||||
for h := range s.HeaderRefs {
|
||||
creds = append(creds, h)
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", s.Name, state, s.URL, dash(strings.Join(creds, ",")), s.Description)
|
||||
}
|
||||
return w.Flush()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newMCPRemoveCmd(client func() *ctlClient) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "remove NAME",
|
||||
Short: "Remove an MCP server: its tools leave /mcp",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := client().delete(cmd.Context(), "/api/v1/mcp/servers/"+args[0], nil); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s removed; its tools leave /mcp within half a minute\n", args[0])
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newMCPTestCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "test NAME",
|
||||
Short: "Ask an MCP server for its tools, with its credentials, as /mcp would",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var v adminapi.MCPTestView
|
||||
if err := client().post(cmd.Context(), "/api/v1/mcp/servers/"+args[0]+"/test", nil, &v); err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(v)
|
||||
}
|
||||
if !v.OK {
|
||||
return errors.New(args[0] + " did not answer: " + v.Error)
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "%s answers with %d tool(s):\n", args[0], len(v.Tools))
|
||||
for _, t := range v.Tools {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), " "+t)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// pairs reads NAME=VALUE flags.
|
||||
func pairs(list []string, flag string) (map[string]string, error) {
|
||||
if len(list) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := map[string]string{}
|
||||
for _, p := range list {
|
||||
k, v, ok := strings.Cut(p, "=")
|
||||
if !ok || strings.TrimSpace(k) == "" {
|
||||
return nil, fmt.Errorf("%s %q is not NAME=VALUE", flag, p)
|
||||
}
|
||||
out[strings.TrimSpace(k)] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -33,7 +34,7 @@ func newGuardJudgeCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
"then fetches and serves by itself: " + knownJudgeNames() + ".",
|
||||
}
|
||||
c.AddCommand(newGuardJudgeStatusCmd(client, asJSON), newGuardJudgeEnableCmd(client),
|
||||
newGuardJudgeUseCmd(client), newGuardJudgeAskCmd(client, asJSON))
|
||||
newGuardJudgeUseCmd(client), newGuardJudgeAskCmd(client, asJSON), newGuardJudgeCheckCmd(client, asJSON))
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -383,3 +384,70 @@ func newGuardJudgeAskCmd(client func() *ctlClient, asJSON *bool) *cobra.Command
|
||||
c.Flags().StringVar(&answer, "answer", "", "an assistant's reply to TEXT, judged as output is")
|
||||
return c
|
||||
}
|
||||
|
||||
func newGuardJudgeCheckCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
var model string
|
||||
var burst int
|
||||
c := &cobra.Command{
|
||||
Use: "check",
|
||||
Short: "How a judge keeps up: a judgment alone and among others, against the time a request waits",
|
||||
Long: "Times three judgments of a prompt of about 280 tokens, one after another, then\n" +
|
||||
"a burst of them at once (two for each ready replica unless --burst says),\n" +
|
||||
"and sets both against the policies' latency budget. A judgment past the\n" +
|
||||
"budget is refused under fail: closed; past half of it alone, or past it\n" +
|
||||
"among others, a busy moment refuses requests. Each request is judged once\n" +
|
||||
"for its prompt and again every 600 characters of its answer.",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
v, err := checkJudge(cmd.Context(), client(), model, burst)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if *asJSON {
|
||||
return emitJSON(v)
|
||||
}
|
||||
fmt.Fprint(cmd.OutOrStdout(), judgeCheckText(v))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&model, "model", "", "the judge (default: the one the policies ask)")
|
||||
c.Flags().IntVar(&burst, "burst", 0, "judgments at once (default: two for each ready replica)")
|
||||
return c
|
||||
}
|
||||
|
||||
func checkJudge(ctx context.Context, c *ctlClient, model string, burst int) (adminapi.JudgeCheckView, error) {
|
||||
body, _ := json.Marshal(adminapi.JudgeCheckRequest{Model: model, Burst: burst})
|
||||
var v adminapi.JudgeCheckView
|
||||
err := c.patient(3*time.Minute).post(ctx, "/api/v1/guard/judges/check", body, &v)
|
||||
return v, err
|
||||
}
|
||||
|
||||
// judgeCheckText says a check in a few lines, and what to do when it is not
|
||||
// fine.
|
||||
func judgeCheckText(v adminapi.JudgeCheckView) string {
|
||||
var b strings.Builder
|
||||
took := make([]string, len(v.TookMs))
|
||||
for i, t := range v.TookMs {
|
||||
took[i] = fmt.Sprintf("%.1f", float64(t)/1000)
|
||||
}
|
||||
fmt.Fprintf(&b, "%s, %d replica(s) ready, a request waits %.1f s for it\n", v.Model, v.Ready, float64(v.BudgetMs)/1000)
|
||||
fmt.Fprintf(&b, " alone: %s s\n", strings.Join(took, ", "))
|
||||
fmt.Fprintf(&b, " %d at once: slowest %.1f s", v.Burst, float64(v.BurstSlowestMs)/1000)
|
||||
if v.BurstFailed > 0 {
|
||||
fmt.Fprintf(&b, ", %d gave no verdict", v.BurstFailed)
|
||||
}
|
||||
fmt.Fprintf(&b, "; about %.1f judgments a second, so about %.1f requests a second with short answers\n",
|
||||
v.PerSecond, v.PerSecond/2)
|
||||
switch v.Verdict {
|
||||
case "ok":
|
||||
b.WriteString("ok: well inside the budget\n")
|
||||
case "tight":
|
||||
b.WriteString("tight: a busy moment passes the budget\n")
|
||||
default:
|
||||
b.WriteString("slow: a judgment alone passes the budget\n")
|
||||
}
|
||||
for _, a := range v.Advice {
|
||||
b.WriteString(" - " + a + "\n")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -32,12 +32,34 @@ type judgeKeeper struct {
|
||||
// tried is when each judge was last asked for, so one that cannot be
|
||||
// fetched is not asked for again on every look.
|
||||
tried map[string]time.Time
|
||||
// grace is how long after its first look the keeper serves no judge
|
||||
// itself: init adds its own judge just after the control plane starts,
|
||||
// sized and placed as the install says, and one the leader added first
|
||||
// was what init then kept. Zero waits for nothing.
|
||||
grace time.Duration
|
||||
first time.Time
|
||||
|
||||
// What refitting an earlier install's judge needs: the catalog to change
|
||||
// its profile in, the replicas to restart, and the nodes' CPUs. Any nil
|
||||
// leaves judges as they are.
|
||||
profiles interface {
|
||||
SetProfiles(ctx context.Context, name string, profiles []catalog.Profile, by string) (catalog.Model, error)
|
||||
}
|
||||
restart func(ids ...string) error
|
||||
replicas func(model string) []string
|
||||
nodeCPUs func() []int
|
||||
// looked is each judge already refitted or found changed by hand, so
|
||||
// each is said once.
|
||||
looked map[string]bool
|
||||
}
|
||||
|
||||
// judgeRetry is how long a judge whose serving failed waits before it is
|
||||
// asked for again.
|
||||
const judgeRetry = 10 * time.Minute
|
||||
|
||||
// judgeGrace is the keeper's grace on a control plane just started.
|
||||
const judgeGrace = 10 * time.Minute
|
||||
|
||||
func (k *judgeKeeper) run(ctx context.Context, every time.Duration) {
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
@@ -69,6 +91,13 @@ func (k *judgeKeeper) check() {
|
||||
have := map[string]bool{}
|
||||
for _, m := range k.models() {
|
||||
have[m.Name] = true
|
||||
k.refit(m)
|
||||
}
|
||||
if k.first.IsZero() {
|
||||
k.first = now()
|
||||
}
|
||||
if now().Sub(k.first) < k.grace {
|
||||
return
|
||||
}
|
||||
busy := map[string]bool{}
|
||||
for _, j := range k.serve.Jobs() {
|
||||
@@ -96,6 +125,15 @@ func (k *judgeKeeper) check() {
|
||||
}
|
||||
k.tried[name] = now()
|
||||
var opts judgeServeOptions
|
||||
if !kj.GPU {
|
||||
// The cores init would give the same number of judges.
|
||||
var cpus []int
|
||||
if k.nodeCPUs != nil {
|
||||
cpus = k.nodeCPUs()
|
||||
}
|
||||
opts.Replicas = 2
|
||||
opts.Cores = refitCores(opts.Replicas, cpus)
|
||||
}
|
||||
if kj.GPU {
|
||||
if opts.GPUClass = roomiestGPUClass(k.gpus()); opts.GPUClass == "" {
|
||||
k.log.Warn("a policy names a judge that runs on a GPU, and this cluster has none",
|
||||
@@ -105,6 +143,8 @@ func (k *judgeKeeper) check() {
|
||||
}
|
||||
req := kj.serveRequest(opts)
|
||||
req.By = "llmgw (a guard policy names it)"
|
||||
// init, or an operator, may add it while this fetches: theirs stays.
|
||||
req.IfAbsent = true
|
||||
job, err := k.serve.Start(req)
|
||||
if err != nil {
|
||||
k.log.Error("could not start serving a judge a policy names", "judge", name, "err", err)
|
||||
@@ -114,3 +154,88 @@ func (k *judgeKeeper) check() {
|
||||
"judge", name, "repo", kj.Repo, "file", kj.File, "job", job.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// refit moves a judge an earlier `server init` installed to what init
|
||||
// installs now, and restarts its replicas onto it. Those judges reserved no
|
||||
// cores or one, 2 or 4 GB, and ran llama.cpp's prompt cache: on fewer than
|
||||
// eight cores every judgment missed its 5 s budget and fail: closed refused
|
||||
// every request, until fixed by hand. A judge whose profile differs in any
|
||||
// other way was set by somebody, and is left as it is, said once.
|
||||
func (k *judgeKeeper) refit(m catalog.Model) {
|
||||
if k.profiles == nil || k.restart == nil || k.replicas == nil || m.Name != defaultJudge || !m.IsGuard() {
|
||||
return
|
||||
}
|
||||
if k.looked == nil {
|
||||
k.looked = map[string]bool{}
|
||||
}
|
||||
if k.looked[m.Name] {
|
||||
return
|
||||
}
|
||||
k.looked[m.Name] = true
|
||||
if !earlierInitJudge(m) {
|
||||
if len(m.Profiles) != 1 || !slices.Contains(m.Profiles[0].Args, "--cache-ram") {
|
||||
k.log.Info("the judge's profile is not one init wrote; left as it is",
|
||||
"judge", m.Name, "see", "llmgw ctl model resources "+m.Name)
|
||||
}
|
||||
return
|
||||
}
|
||||
var cpus []int
|
||||
if k.nodeCPUs != nil {
|
||||
cpus = k.nodeCPUs()
|
||||
}
|
||||
p := m.Profiles[0]
|
||||
p.MinMemoryGB = judgeMemoryGB
|
||||
p.CPUCores = refitCores(m.Scaling.Min, cpus)
|
||||
p.Args = []string{"--ctx-size", "8192", "--cache-ram", "0"}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
if _, err := k.profiles.SetProfiles(ctx, m.Name, []catalog.Profile{p}, "llmgw (the judge an earlier init installed)"); err != nil {
|
||||
k.log.Warn("could not refit the judge an earlier init installed", "judge", m.Name, "err", err)
|
||||
k.looked[m.Name] = false
|
||||
return
|
||||
}
|
||||
ids := k.replicas(m.Name)
|
||||
if len(ids) > 0 {
|
||||
if err := k.restart(ids...); err != nil {
|
||||
k.log.Warn("refitted the judge, but could not restart it", "judge", m.Name, "err", err)
|
||||
}
|
||||
}
|
||||
k.log.Info("refitted the judge an earlier init installed", "judge", m.Name,
|
||||
"memory_gb", p.MinMemoryGB, "cpu_cores", p.CPUCores, "replicas_restarted", len(ids))
|
||||
}
|
||||
|
||||
// earlierInitJudge is whether a judge's entry is exactly what `server init`
|
||||
// before v0.14.7 wrote: one llama.cpp profile on CPUs, an 8192 context and
|
||||
// no other args, 2 or 4 GB, none to two cores.
|
||||
func earlierInitJudge(m catalog.Model) bool {
|
||||
if len(m.Profiles) != 1 {
|
||||
return false
|
||||
}
|
||||
p := m.Profiles[0]
|
||||
return p.Name == "cpu" && p.Engine == "llamacpp" && p.GPUClass == "" && p.GPU == nil &&
|
||||
len(p.NodeSelector) == 0 && slices.Equal(p.Args, []string{"--ctx-size", "8192"}) &&
|
||||
(p.MinMemoryGB == 2 || p.MinMemoryGB == 4) && p.CPUCores >= 0 && p.CPUCores <= 2
|
||||
}
|
||||
|
||||
// refitCores is a judge replica's cores by init's rule: up to four for the
|
||||
// one judge of a cluster, two each where there are more, fewer where the
|
||||
// smallest node cannot hold them all.
|
||||
func refitCores(replicas int, cpus []int) int {
|
||||
smallest := 0
|
||||
for _, c := range cpus {
|
||||
if c > 0 && (smallest == 0 || c < smallest) {
|
||||
smallest = c
|
||||
}
|
||||
}
|
||||
if replicas <= 1 {
|
||||
if smallest == 0 {
|
||||
return judgeCores
|
||||
}
|
||||
return min(smallest, judgeCoresAlone)
|
||||
}
|
||||
cores := judgeCores
|
||||
if len(cpus) == 1 && smallest > 0 && replicas*cores > smallest {
|
||||
cores = max(1, smallest/replicas)
|
||||
}
|
||||
return cores
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"slices"
|
||||
@@ -51,7 +52,8 @@ func TestAJudgeAPolicyNamesIsFetchedWhenTheCatalogLacksIt(t *testing.T) {
|
||||
// The GPU judge alone: the CPU one is there, and one llmgw does not know
|
||||
// is not guessed at.
|
||||
if len(fs.started) != 1 || fs.started[0].Name != "qwen3guard-4b" || fs.started[0].GPUClass != "6000-24g" ||
|
||||
fs.started[0].Class != catalog.ClassStandard || fs.started[0].GuardFormat != "qwen3guard" || fs.started[0].CPU {
|
||||
fs.started[0].Class != catalog.ClassStandard || fs.started[0].GuardFormat != "qwen3guard" || fs.started[0].CPU ||
|
||||
!fs.started[0].IfAbsent {
|
||||
t.Fatalf("started %+v", fs.started)
|
||||
}
|
||||
|
||||
@@ -90,8 +92,12 @@ func TestAJudgeAPolicyNamesIsFetchedWhenTheCatalogLacksIt(t *testing.T) {
|
||||
lib = guard.Config{Policies: map[string]guard.Policy{"b": {Judge: &guard.JudgePolicy{Model: "qwen3guard-0.6b", Input: "block"}}}}
|
||||
inCatalog = nil
|
||||
k.gpus = func() []adminapi.ClassUse { return nil }
|
||||
k.nodeCPUs = func() []int { return []int{8, 4} }
|
||||
k.check()
|
||||
if last := fs.started[len(fs.started)-1]; last.Name != "qwen3guard-0.6b" || !last.CPU || last.Replicas != 2 {
|
||||
// Sized as init sizes its two judges: 2 GB, and two cores each on a
|
||||
// cluster whose smallest node has four.
|
||||
if last := fs.started[len(fs.started)-1]; last.Name != "qwen3guard-0.6b" || !last.CPU || last.Replicas != 2 ||
|
||||
last.MemoryGB != judgeMemoryGB || last.CPUCores != refitCores(2, []int{8, 4}) || last.CPUCores == 0 {
|
||||
t.Errorf("CPU judge: %+v", last)
|
||||
}
|
||||
fs.err = errors.New("the mirror is down")
|
||||
@@ -124,3 +130,108 @@ func TestCategoriesFollowTheJudges(t *testing.T) {
|
||||
t.Errorf("no categories (every one counts) became %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeProfiles struct {
|
||||
set map[string][]catalog.Profile
|
||||
}
|
||||
|
||||
func (f *fakeProfiles) SetProfiles(_ context.Context, name string, ps []catalog.Profile, _ string) (catalog.Model, error) {
|
||||
if f.set == nil {
|
||||
f.set = map[string][]catalog.Profile{}
|
||||
}
|
||||
f.set[name] = ps
|
||||
return catalog.Model{Name: name, Profiles: ps}, nil
|
||||
}
|
||||
|
||||
// A judge that v0.14.6 or earlier's init installed is moved to today's
|
||||
// figures, once, and its replicas restarted onto them; one somebody changed
|
||||
// is left alone.
|
||||
func TestAnEarlierInstallsJudgeIsRefittedOnce(t *testing.T) {
|
||||
lib := guard.Config{Policies: map[string]guard.Policy{
|
||||
"baseline": {Judge: &guard.JudgePolicy{Model: "qwen3guard-0.6b", Input: "block"}},
|
||||
}}
|
||||
earlier := guardModel("http://10.0.0.5:8081/qwen3guard-0.6b/guard/g.gguf", 2, 1, nil)
|
||||
earlier.Profiles[0].MinMemoryGB, earlier.Profiles[0].Args = 4, []string{"--ctx-size", "8192"}
|
||||
inCatalog := []catalog.Model{earlier}
|
||||
fp := &fakeProfiles{}
|
||||
var restarted []string
|
||||
k := &judgeKeeper{
|
||||
library: func() guard.Config { return lib },
|
||||
models: func() []catalog.Model { return inCatalog },
|
||||
serve: &fakeServe{},
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
profiles: fp,
|
||||
restart: func(ids ...string) error { restarted = append(restarted, ids...); return nil },
|
||||
replicas: func(string) []string { return []string{"j-1", "j-2"} },
|
||||
nodeCPUs: func() []int { return []int{4} },
|
||||
}
|
||||
k.check()
|
||||
got := fp.set["qwen3guard-0.6b"]
|
||||
if len(got) != 1 || got[0].MinMemoryGB != 2 || got[0].CPUCores != 2 || !slices.Contains(got[0].Args, "--cache-ram") {
|
||||
t.Fatalf("refitted to %+v", got)
|
||||
}
|
||||
if !slices.Equal(restarted, []string{"j-1", "j-2"}) {
|
||||
t.Errorf("restarted %v", restarted)
|
||||
}
|
||||
k.check()
|
||||
if len(restarted) != 2 {
|
||||
t.Error("refitted twice")
|
||||
}
|
||||
|
||||
// Changed by somebody: an extra arg. Left as it is.
|
||||
mine := guardModel("http://x/g.gguf", 2, 3, nil)
|
||||
mine.Profiles[0].Args = []string{"--ctx-size", "16384"}
|
||||
inCatalog = []catalog.Model{mine}
|
||||
fp.set, k.looked = nil, nil
|
||||
k.check()
|
||||
if fp.set != nil {
|
||||
t.Errorf("a judge set by hand was changed: %+v", fp.set)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAJudgeIsRefittedToTheCoresInitWouldGive(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
replicas int
|
||||
cpus []int
|
||||
want int
|
||||
}{
|
||||
{1, []int{4}, 4},
|
||||
{1, []int{2}, 2},
|
||||
{1, []int{16}, 4},
|
||||
{2, []int{4}, 2},
|
||||
{2, []int{2}, 1},
|
||||
{2, []int{4, 32}, 2},
|
||||
{2, nil, 2},
|
||||
} {
|
||||
if got := refitCores(c.replicas, c.cpus); got != c.want {
|
||||
t.Errorf("%d judges on %v: %d cores, want %d", c.replicas, c.cpus, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Just after the control plane starts, the judge init is adding is left to
|
||||
// init: the keeper serves one itself only after its grace.
|
||||
func TestTheKeeperLeavesAJustStartedInstallsJudgeToInit(t *testing.T) {
|
||||
lib := guard.Config{Policies: map[string]guard.Policy{"b": {Judge: &guard.JudgePolicy{Model: "qwen3guard-0.6b", Input: "block"}}}}
|
||||
now := time.Date(2026, 10, 19, 12, 0, 0, 0, time.UTC)
|
||||
fs := &fakeServe{}
|
||||
k := &judgeKeeper{
|
||||
library: func() guard.Config { return lib },
|
||||
models: func() []catalog.Model { return nil },
|
||||
serve: fs, grace: judgeGrace,
|
||||
gpus: func() []adminapi.ClassUse { return nil },
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
k.check()
|
||||
now = now.Add(judgeGrace / 2)
|
||||
k.check()
|
||||
if len(fs.started) != 0 {
|
||||
t.Fatalf("served within its grace: %+v", fs.started)
|
||||
}
|
||||
now = now.Add(judgeGrace)
|
||||
k.check()
|
||||
if len(fs.started) != 1 {
|
||||
t.Errorf("never served after its grace: %d", len(fs.started))
|
||||
}
|
||||
}
|
||||
@@ -142,7 +142,9 @@ func (k knownJudge) serveRequest(o judgeServeOptions) modelserve.Request {
|
||||
}
|
||||
return req
|
||||
}
|
||||
req.CPU, req.CPUCores = true, o.Cores
|
||||
// Sized as init sizes its judge: the memory it measured to need with
|
||||
// no prompt cache, and the cores its rule gives.
|
||||
req.CPU, req.CPUCores, req.MemoryGB = true, o.Cores, judgeMemoryGB
|
||||
if req.Replicas == 0 {
|
||||
req.Replicas = 2
|
||||
}
|
||||
|
||||
+27
-1
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path"
|
||||
"slices"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
@@ -301,7 +302,8 @@ func newLimitsCmd(open opener) *cobra.Command {
|
||||
var perMember bool
|
||||
var rpm, tpm int
|
||||
var budget float64
|
||||
var models []string
|
||||
var models, tools []string
|
||||
var replace bool
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "limits",
|
||||
@@ -357,7 +359,19 @@ func newLimitsCmd(open opener) *cobra.Command {
|
||||
return errors.New("--per-member is for --scope org, team or project")
|
||||
}
|
||||
}
|
||||
// Only what is given changes: setting a tool grant must not
|
||||
// clear the scope's rate or budget. --replace writes the row
|
||||
// whole, a flag not given then no limit, as the console's
|
||||
// form does with every field it shows.
|
||||
l := store.Limits{Scope: level, ScopeID: scopeID}
|
||||
if !replace {
|
||||
switch cur, err := s.LimitsFor(cmd.Context(), level, scopeID); {
|
||||
case err == nil:
|
||||
l = cur
|
||||
case !errors.Is(err, store.ErrNotFound):
|
||||
return err
|
||||
}
|
||||
}
|
||||
if cmd.Flags().Changed("rpm") {
|
||||
l.RPM = &rpm
|
||||
}
|
||||
@@ -379,6 +393,14 @@ func newLimitsCmd(open opener) *cobra.Command {
|
||||
}
|
||||
l.AllowedModels = models
|
||||
}
|
||||
if cmd.Flags().Changed("tools") {
|
||||
for _, p := range tools {
|
||||
if _, err := path.Match(p, "x.y"); err != nil {
|
||||
return fmt.Errorf("--tools %q is not a pattern: %w", p, err)
|
||||
}
|
||||
}
|
||||
l.AllowedTools = tools
|
||||
}
|
||||
if err := s.PutLimits(cmd.Context(), l); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -395,6 +417,10 @@ func newLimitsCmd(open opener) *cobra.Command {
|
||||
cmd.Flags().Float64Var(&budget, "budget", 0, "budget per period, in whole units as the quotas page shows them")
|
||||
cmd.Flags().StringVar(&period, "period", "month", "day | month")
|
||||
cmd.Flags().StringSliceVar(&models, "models", nil, "allowed models; empty allows none")
|
||||
cmd.Flags().BoolVar(&replace, "replace", false,
|
||||
"write every limit at the scope: one not given is then no limit there (default: only what is given changes)")
|
||||
cmd.Flags().StringSliceVar(&tools, "tools", nil,
|
||||
"the company MCP servers' tools granted here, as patterns: jira.*,db.query; none granted anywhere is none")
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -51,6 +51,7 @@ func newMirrorCmd() *cobra.Command {
|
||||
newMirrorRemoveCmd(&dir),
|
||||
newMirrorServeCmd(&dir),
|
||||
newMirrorInitCmd(&dir),
|
||||
newMirrorAddPeerCmd(&dir),
|
||||
)
|
||||
return c
|
||||
}
|
||||
@@ -255,6 +256,7 @@ func newMirrorServeCmd(dir *string) *cobra.Command {
|
||||
var tlsc tlsconf.Config
|
||||
var allow, clients []string
|
||||
var allowPrivate bool
|
||||
var peers []string
|
||||
|
||||
c := &cobra.Command{
|
||||
Use: "serve",
|
||||
@@ -317,6 +319,21 @@ func newMirrorServeCmd(dir *string) *cobra.Command {
|
||||
ReadTimeout: time.Minute,
|
||||
})
|
||||
|
||||
// Another mirror kept in step with this one, each copying what
|
||||
// the other has: the cluster's models while one machine is down.
|
||||
if len(peers) > 0 {
|
||||
p := &mirror.Peers{M: mgr, Log: log}
|
||||
for _, addr := range peers {
|
||||
c, err := mirror.NewClient(addr, tlsc, 30*time.Second)
|
||||
if err != nil {
|
||||
return fmt.Errorf("--peer %s: %w", addr, err)
|
||||
}
|
||||
p.Clients = append(p.Clients, c)
|
||||
}
|
||||
go p.Run(cmd.Context())
|
||||
fmt.Fprintf(out, "kept in step with %s, every %s\n", strings.Join(peers, ", "), mirror.PeerInterval)
|
||||
}
|
||||
|
||||
errs := make(chan error, 2)
|
||||
go func() { errs <- fmt.Errorf("serving weights: %w", files.ListenAndServe()) }()
|
||||
fmt.Fprintf(out, "serving %s on %s\n", *dir, listen)
|
||||
@@ -343,6 +360,9 @@ func newMirrorServeCmd(dir *string) *cobra.Command {
|
||||
},
|
||||
}
|
||||
c.Flags().StringVar(&listen, "listen", ":8081", "address to serve the weights on")
|
||||
c.Flags().StringArrayVar(&peers, "peer", nil,
|
||||
"another mirror's management API to keep in step with, each copying what the other has; repeatable. "+
|
||||
"Give each the other's address in --api-allow (or its certificate in --api-client)")
|
||||
c.Flags().StringVar(&apiListen, "api-listen", ":8082", "address to serve the management API on")
|
||||
c.Flags().StringVar(&base, "base", "http://localhost:8081",
|
||||
"the address nodes use to reach this mirror, as it should appear in catalog sources")
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||
)
|
||||
|
||||
// A second mirror, so models can still be added and fetched while the first
|
||||
// machine is down. Set up from the machine of the first, which it is kept in
|
||||
// step with: the first is told of it, it of the first, and the control
|
||||
// planes of both.
|
||||
|
||||
func newMirrorAddPeerCmd(dir *string) *cobra.Command {
|
||||
var controlPlanes []string
|
||||
var base string
|
||||
cmd := &cobra.Command{
|
||||
Use: "add-peer [user@]host",
|
||||
Short: "Set up a second mirror on another machine, kept in step with this one",
|
||||
Long: `Run on the machine of the cluster's mirror, as root. It logs in to HOST with
|
||||
ssh, installs llmgw there if it is not, and sets up a mirror that keeps in
|
||||
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.
|
||||
|
||||
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",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if os.Geteuid() != 0 {
|
||||
return errors.New("add-peer changes this mirror's service and the cluster's settings: run it as root")
|
||||
}
|
||||
user, host, ok := strings.Cut(args[0], "@")
|
||||
if !ok {
|
||||
user, host = os.Getenv("SUDO_USER"), args[0]
|
||||
}
|
||||
return addMirrorPeer(cmd.Context(), cmd.OutOrStdout(), &onboard.SSH{Addr: host, User: user}, host,
|
||||
*dir, base, controlPlanes, "/etc/llmgw/server.yaml")
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArrayVar(&controlPlanes, "control-plane", nil,
|
||||
"address of a control plane that manages the mirrors; repeatable (default: this machine)")
|
||||
cmd.Flags().StringVar(&base, "base", "", "the address nodes fetch from on HOST (default: http://HOST:8081)")
|
||||
return cmd
|
||||
}
|
||||
|
||||
// addMirrorPeer sets up a mirror on a machine reached by remote, kept in
|
||||
// step with the one on this machine.
|
||||
func addMirrorPeer(ctx context.Context, out io.Writer, remote onboard.Remote, host, dir, base string,
|
||||
controlPlanes []string, configPath string,
|
||||
) error {
|
||||
ip, err := ipv4Of(host)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
here := primaryAddr()
|
||||
if len(controlPlanes) == 0 {
|
||||
controlPlanes = []string{here}
|
||||
}
|
||||
if base == "" {
|
||||
base = "http://" + net.JoinHostPort(ip, "8081")
|
||||
}
|
||||
thisAPI := "http://" + net.JoinHostPort(here, "8082")
|
||||
peerAPI := "http://" + net.JoinHostPort(ip, "8082")
|
||||
|
||||
// llmgw there, the same as here, when it has none.
|
||||
if _, err := remote.Run(ctx, "command -v llmgw >/dev/null", nil); err != nil {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bin, err := os.ReadFile(exe) //nolint:gosec // this program
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, " .. llmgw to %s\n", host)
|
||||
if _, err := remote.Run(ctx, "sudo install -m 755 /dev/stdin /usr/local/bin/llmgw", bin); err != nil {
|
||||
return fmt.Errorf("installing llmgw on %s: %w", host, err)
|
||||
}
|
||||
}
|
||||
|
||||
// The mirror there: its management API for the control planes and this
|
||||
// mirror, which reads it; kept in step with this one.
|
||||
allow := append([]string{}, controlPlanes...)
|
||||
if !slices.Contains(allow, here) {
|
||||
allow = append(allow, here)
|
||||
}
|
||||
script := "sudo llmgw mirror init --dir " + shellQuote(dir) + " --base " + shellQuote(base) + " --peer " + shellQuote(thisAPI)
|
||||
for _, a := range allow {
|
||||
script += " --control-plane " + shellQuote(a)
|
||||
}
|
||||
fmt.Fprintf(out, " .. the mirror on %s\n", host)
|
||||
if b, err := remote.Run(ctx, script, nil); err != nil {
|
||||
return fmt.Errorf("the mirror on %s: %w\n%s", host, err, b)
|
||||
}
|
||||
|
||||
// This mirror told of it, and to let it read this one's API.
|
||||
changed, err := addToMirrorUnit(mirrorUnitPath, peerAPI, ip)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
in := &serverInit{out: out}
|
||||
if err := in.systemctl(ctx, "daemon-reload"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := in.systemctl(ctx, "restart", "llmgw-mirror"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(out, " ok this mirror keeps in step with %s\n", peerAPI)
|
||||
|
||||
// The control planes told of both.
|
||||
if err := addMirrorPeerSetting(configPath, peerAPI); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Fprintf(out, " ok mirror.peers names %s; the control planes use it while this one is down\n", peerAPI)
|
||||
return nil
|
||||
}
|
||||
|
||||
// addToMirrorUnit adds a peer and the address allowed to read this mirror's
|
||||
// API to its systemd unit, and says whether it changed.
|
||||
func addToMirrorUnit(path, peer, allow string) (bool, error) {
|
||||
b, err := os.ReadFile(path) //nolint:gosec // the mirror's unit
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("this machine has no mirror to keep in step (%s): run add-peer on the mirror's machine", path)
|
||||
}
|
||||
unit := string(b)
|
||||
re := regexp.MustCompile(`(?m)^ExecStart=.*$`)
|
||||
line := re.FindString(unit)
|
||||
if line == "" {
|
||||
return false, fmt.Errorf("%s has no ExecStart", path)
|
||||
}
|
||||
next := line
|
||||
if !strings.Contains(next, "--peer "+peer) {
|
||||
next += " --peer " + peer
|
||||
}
|
||||
if !strings.Contains(next, "--api-allow "+allow+" ") && !strings.HasSuffix(next, "--api-allow "+allow) &&
|
||||
strings.Contains(next, "--api-listen :") {
|
||||
next += " --api-allow " + allow
|
||||
}
|
||||
if next == line {
|
||||
return false, nil
|
||||
}
|
||||
return true, os.WriteFile(path, []byte(strings.Replace(unit, line, next, 1)), 0o644)
|
||||
}
|
||||
|
||||
// addMirrorPeerSetting names a peer in mirror.peers: in the cluster's
|
||||
// settings when the store holds them, else in server.yaml.
|
||||
func addMirrorPeerSetting(path, peer string) error {
|
||||
add := func(doc *settings.Doc) error {
|
||||
var peers []string
|
||||
if n := doc.Node([]string{"mirror", "peers"}); n != nil {
|
||||
_ = n.Decode(&peers)
|
||||
}
|
||||
if slices.Contains(peers, peer) {
|
||||
return nil
|
||||
}
|
||||
list := "[" + strings.Join(append(peers, peer), ", ") + "]"
|
||||
return doc.SetYAML([]string{"mirror", "peers"}, list)
|
||||
}
|
||||
changed, err := changeStoredSettings(path, "a second mirror (llmgw mirror add-peer)", add)
|
||||
if err != nil || changed {
|
||||
return err
|
||||
}
|
||||
doc, err := settings.Open(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := add(doc); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = doc.Save(nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// ensureSecondMirror sets up the mirror kept in step with this machine's on
|
||||
// the store machine chosen, reached as the store's setup reached it.
|
||||
func (in *serverInit) ensureSecondMirror(ctx context.Context) error {
|
||||
if !in.standbys {
|
||||
return errors.New("it needs --standbys: without them this mirror's API answers this machine alone, which the second cannot read")
|
||||
}
|
||||
i := in.secondMirror - 1
|
||||
if i < 0 || i >= len(in.patroniNodes) {
|
||||
return fmt.Errorf("there is no database machine %d to put it on", in.secondMirror)
|
||||
}
|
||||
if in.noMirror || in.mirrorURL != "" {
|
||||
return errors.New("it keeps in step with a mirror on this machine, and there is none")
|
||||
}
|
||||
n := in.patroniNodes[i]
|
||||
fmt.Fprintf(in.out, " .. a second mirror on %s\n", n.Name)
|
||||
if err := addMirrorPeer(ctx, in.out, n.Remote, n.IP, in.mirrorRoot(), "", in.mirrorAllow(), in.configPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("a second mirror on %s (%s), kept in step with this one", n.Name, n.IP)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAPeerIsAddedToTheMirrorsUnitOnce(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "llmgw-mirror.service")
|
||||
unit := mirrorUnitFor("/srv/models", ":8082", "http://10.0.0.10:8081", []string{"10.0.0.10"}, nil, "test")
|
||||
if err := os.WriteFile(path, []byte(unit), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changed, err := addToMirrorUnit(path, "http://10.0.0.12:8082", "10.0.0.12")
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("changed %v %v", changed, err)
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if !strings.Contains(string(b), "--peer http://10.0.0.12:8082") || !strings.Contains(string(b), "--api-allow 10.0.0.12") {
|
||||
t.Errorf("unit:\n%s", b)
|
||||
}
|
||||
if changed, _ := addToMirrorUnit(path, "http://10.0.0.12:8082", "10.0.0.12"); changed {
|
||||
t.Error("added twice")
|
||||
}
|
||||
// A mirror whose API is on loopback alone is not opened by the back door.
|
||||
lo := mirrorUnitFor("/srv/models", "127.0.0.1:8082", "http://10.0.0.10:8081", nil, nil, "test")
|
||||
_ = os.WriteFile(path, []byte(lo), 0o644)
|
||||
_, _ = addToMirrorUnit(path, "http://10.0.0.12:8082", "10.0.0.12")
|
||||
if b, _ := os.ReadFile(path); strings.Contains(string(b), "--api-allow") {
|
||||
t.Errorf("a loopback mirror was opened:\n%s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASecondMirrorIsNamedInTheSettings(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "server.yaml")
|
||||
if err := os.WriteFile(path, []byte("listen: \":8080\"\nmirror:\n api: http://10.0.0.10:8082\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
if err := addMirrorPeerSetting(path, "http://10.0.0.12:8082"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
b, _ := os.ReadFile(path)
|
||||
if strings.Count(string(b), "10.0.0.12:8082") != 1 || !strings.Contains(string(b), "peers") {
|
||||
t.Errorf("server.yaml:\n%s", b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSetupOffersASecondMirrorOnAStoreMachine(t *testing.T) {
|
||||
in := newDefaultServerInit()
|
||||
f := newInstallForm(&in, "ctl", []string{"10.0.0.5"}, true)
|
||||
f.secondMirror.Choice = 1
|
||||
if !f.secondMirror.Show() {
|
||||
t.Fatal("not offered with the mirror on this machine")
|
||||
}
|
||||
f.mirrorWhere.Choice = 1
|
||||
if f.secondMirror.Show() {
|
||||
t.Error("offered with the mirror elsewhere")
|
||||
}
|
||||
f.mirrorWhere.Choice = 0
|
||||
in.secondMirror = 1
|
||||
in.patroni = []string{"ubuntu@10.0.0.11", "ubuntu@10.0.0.12"}
|
||||
if got := strings.Join(in.initFlags(), " "); !strings.Contains(got, "--second-mirror 1") {
|
||||
t.Errorf("flags %s", got)
|
||||
}
|
||||
// Without standbys this mirror answers itself alone: the second could not read it.
|
||||
in.standbys = false
|
||||
if err := in.ensureSecondMirror(t.Context()); err == nil || !strings.Contains(err.Error(), "standbys") {
|
||||
t.Errorf("without standbys: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
func newMirrorInitCmd(dir *string) *cobra.Command {
|
||||
var (
|
||||
controlPlanes []string
|
||||
peers []string
|
||||
base string
|
||||
noFirewall bool
|
||||
)
|
||||
@@ -61,7 +62,7 @@ Safe to run again: it changes only what differs.`,
|
||||
if _, err := parseAllow(controlPlanes); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := mirrorInit(cmd.Context(), cmd.OutOrStdout(), *dir, base, controlPlanes); err != nil {
|
||||
if err := mirrorInit(cmd.Context(), cmd.OutOrStdout(), *dir, base, controlPlanes, peers); err != nil {
|
||||
return err
|
||||
}
|
||||
if !noFirewall {
|
||||
@@ -80,6 +81,9 @@ Safe to run again: it changes only what differs.`,
|
||||
}
|
||||
cmd.Flags().StringArrayVar(&controlPlanes, "control-plane", nil,
|
||||
"address of the control plane that manages this mirror; repeatable")
|
||||
cmd.Flags().StringArrayVar(&peers, "peer", nil,
|
||||
"another mirror's management API to keep in step with, http://host:8082; repeatable. "+
|
||||
"Its machine must be among --control-plane, to read this one")
|
||||
cmd.Flags().BoolVar(&noFirewall, "no-firewall", false,
|
||||
"leave the firewall alone, instead of keeping the mirror's ports to the cluster's networks")
|
||||
cmd.Flags().StringVar(&base, "base", "",
|
||||
@@ -87,7 +91,7 @@ Safe to run again: it changes only what differs.`,
|
||||
return cmd
|
||||
}
|
||||
|
||||
func mirrorInit(ctx context.Context, out io.Writer, dir, base string, controlPlanes []string) error {
|
||||
func mirrorInit(ctx context.Context, out io.Writer, dir, base string, controlPlanes, peers []string) error {
|
||||
in := &serverInit{user: "llmgw", stateDir: "/var/lib/llmgw", out: out}
|
||||
if err := in.ensureUser(ctx); err != nil {
|
||||
return err
|
||||
@@ -105,7 +109,7 @@ func mirrorInit(ctx context.Context, out io.Writer, dir, base string, controlPla
|
||||
}
|
||||
base = "http://" + net.JoinHostPort(addr, "8081")
|
||||
}
|
||||
unit := mirrorUnitFor(dir, ":8082", base, controlPlanes,
|
||||
unit := mirrorUnitFor(dir, ":8082", base, controlPlanes, peers,
|
||||
"'llmgw mirror init'. Bytes on :8081 for every node; the\n# management API on :8082, for the control plane only.")
|
||||
changed, err := in.writeUnit(mirrorUnitPath, unit)
|
||||
if err != nil {
|
||||
|
||||
+57
-1
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/modelserve"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/sourcecheck"
|
||||
)
|
||||
|
||||
@@ -33,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(newModelAddCmd(client), newModelPresetsCmd(client, asJSON), newModelRemoveCmd(client), newModelPlanCmd(client),
|
||||
c.AddCommand(newModelContextCmd(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),
|
||||
@@ -386,3 +387,58 @@ func newAdaptersCmd(client func() *ctlClient) *cobra.Command {
|
||||
c.Flags().StringArrayVar(&remove, "remove", nil, "an adapter's name; repeatable")
|
||||
return c
|
||||
}
|
||||
|
||||
func newModelContextCmd(client func() *ctlClient) *cobra.Command {
|
||||
var slots int
|
||||
c := &cobra.Command{
|
||||
Use: "context <model> <tokens>",
|
||||
Short: "Change how much one request to a served model may use",
|
||||
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" +
|
||||
"changes all three together. It refuses more than the model was trained for,\n" +
|
||||
"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.",
|
||||
Example: " llmgw ctl model context qwen3-coder-30b 65536\n" +
|
||||
" llmgw ctl model context qwen2.5-7b 16384 --slots 4",
|
||||
Args: cobra.ExactArgs(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])
|
||||
}
|
||||
body, _ := json.Marshal(modelserve.ContextRequest{Tokens: tokens, Slots: slots})
|
||||
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()
|
||||
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)
|
||||
}
|
||||
fmt.Fprintln(out)
|
||||
for _, p := range v.Profiles {
|
||||
line := " " + p.Profile + " (" + p.Engine + "): " + strings.Join(p.Args, " ")
|
||||
switch {
|
||||
case p.Before != "" && p.After != "":
|
||||
line += "; " + p.Before + " → " + p.After
|
||||
case p.After != "":
|
||||
line += "; " + p.After
|
||||
}
|
||||
fmt.Fprintln(out, line)
|
||||
}
|
||||
if v.Note != "" {
|
||||
fmt.Fprintln(out, " note: "+v.Note)
|
||||
}
|
||||
if v.Restarted > 0 {
|
||||
fmt.Fprintf(out, "%d replica(s) started again on it, each new one before the old stops: llmgw ctl replicas --model %s\n", v.Restarted, v.Model)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
c.Flags().IntVar(&slots, "slots", 0, "llama.cpp: requests a replica answers at once (default: as it is)")
|
||||
return c
|
||||
}
|
||||
@@ -69,6 +69,8 @@ func newModelServeCmd(client func() *ctlClient) *cobra.Command {
|
||||
f.IntVar(&req.Nodes, "nodes", 0, "with --gpu-class: the nodes one replica spans, over a fabric (vLLM only)")
|
||||
f.IntVar(&req.TP, "tp", 0, "the older name of --gpus")
|
||||
_ = f.MarkHidden("tp")
|
||||
f.StringToStringVar(&req.Env, "env", nil,
|
||||
"KEY=VALUE in the engine's environment on every node, e.g. NCCL_DEBUG=INFO; repeatable")
|
||||
f.BoolVar(&req.Fallbacks, "fallbacks", false, "also add the next best ways to run it, used when the first is busy")
|
||||
f.BoolVar(&detach, "detach", false, "start it and return: it runs on the control plane; `llmgw ctl jobs` follows it")
|
||||
f.StringVar(&req.Name, "name", "", "the catalog name (default: from the repository)")
|
||||
|
||||
@@ -291,14 +291,14 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
||||
}
|
||||
fmt.Fprintln(out)
|
||||
w := tabTo(out)
|
||||
fmt.Fprintln(w, "FILE\tSIZE\tBEST WAY\tHOLDS\tOF\tNOW")
|
||||
fmt.Fprintln(w, "FILE\tSIZE\tBEST WAY\tA CARD\tOF\tALL CARDS\tNOW")
|
||||
for _, a := range p.Artifacts {
|
||||
name := a.Name
|
||||
if a.Shards > 1 && a.Format == "gguf" {
|
||||
name += fmt.Sprintf(" (%d parts)", a.Shards)
|
||||
}
|
||||
if a.Best == nil {
|
||||
fmt.Fprintf(w, "%s\t%s\tfits nowhere\t\t\t\n", name, bytesShort(a.Bytes))
|
||||
fmt.Fprintf(w, "%s\t%s\tfits nowhere\t\t\t\t\n", name, bytesShort(a.Bytes))
|
||||
continue
|
||||
}
|
||||
b := a.Best
|
||||
@@ -306,11 +306,11 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
||||
if b.FreeNow {
|
||||
now = "free"
|
||||
}
|
||||
holds := fmt.Sprintf("%.1f GB a card", b.NeedGB)
|
||||
holds, all := fmt.Sprintf("%.1f GB", b.NeedGB), fmt.Sprintf("%.0f GB", b.TotalGB())
|
||||
if b.CPU {
|
||||
holds = fmt.Sprintf("%.1f GB of RAM", b.NeedGB)
|
||||
holds, all = fmt.Sprintf("%.1f GB of RAM", b.NeedGB), "-"
|
||||
}
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%.0f GB\t%s\n", name, bytesShort(a.Bytes), b.Label(), holds, b.HaveGB, now)
|
||||
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%.0f GB\t%s\t%s\n", name, bytesShort(a.Bytes), b.Label(), holds, b.HaveGB, all, now)
|
||||
}
|
||||
_ = w.Flush()
|
||||
rec, ok := p.Recommended()
|
||||
@@ -318,7 +318,11 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
||||
fmt.Fprintln(out, "\nNo file fits this cluster as it is: a smaller context (--context), or more memory.")
|
||||
return errDoesNotFit
|
||||
}
|
||||
fmt.Fprintf(out, "\nBest: %s on %s.\n", rec.Artifact, rec.Label())
|
||||
fmt.Fprintf(out, "\nBest: %s on %s: %s.\n", rec.Artifact, rec.Label(), rec.MemoryText())
|
||||
if p.KVBytesPerToken > 0 {
|
||||
fmt.Fprintf(out, "The context costs %.2f GB per 1000 tokens, the whole model (--context to plan another).\n",
|
||||
modelplan.GB(p.KVBytesPerToken*1000))
|
||||
}
|
||||
if len(p.Options) > 1 {
|
||||
fmt.Fprintln(out, "\nEvery way, best first:")
|
||||
w = tabTo(out)
|
||||
@@ -327,7 +331,7 @@ func planFiles(cmd *cobra.Command, client func() *ctlClient, repo, format string
|
||||
if o.FreeNow {
|
||||
now = "free"
|
||||
}
|
||||
fmt.Fprintf(w, " %s\t%s\t%.1f of %.0f GB\t%d host(s)\t%s\n", o.Artifact, o.Label(), o.NeedGB, o.HaveGB, o.Hosts, now)
|
||||
fmt.Fprintf(w, " %s\t%s\t%s\t%d host(s)\t%s\n", o.Artifact, o.Label(), o.MemoryText(), o.Hosts, now)
|
||||
}
|
||||
_ = w.Flush()
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ func (na *nodeAdd) prepare() error {
|
||||
}
|
||||
// Where the mirror serves model files, for the node to be asked to reach.
|
||||
if cfg, err := loadEffectiveConfig(na.serverCfg); err == nil && cfg.Mirror.API != "" {
|
||||
if mc, err := mirror.NewClient(cfg.Mirror.API, cfg.Mirror.TLS, 5*time.Second); err == nil {
|
||||
if mc, err := mirror.NewFailoverClient(append([]string{cfg.Mirror.API}, cfg.Mirror.Peers...), cfg.Mirror.TLS, 5*time.Second); err == nil {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
if list, err := mc.List(ctx); err == nil {
|
||||
o.MirrorBase = list.Base
|
||||
|
||||
@@ -131,3 +131,33 @@ func TestKeyBudgetsAreWholeUnits(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A limit given alone changes that limit alone: granting a tool keeps the
|
||||
// scope's rate. --replace writes the row whole, as the console's form does.
|
||||
func TestALimitGivenAloneLeavesTheOthersAtTheScope(t *testing.T) {
|
||||
db := filepath.Join(t.TempDir(), "s.db")
|
||||
mustRun(t, db, "org", "add", "org", "Acme")
|
||||
mustRun(t, db, "org", "add", "team", "platform")
|
||||
mustRun(t, db, "org", "add", "project", "gateway")
|
||||
mustRun(t, db, "keys", "limits", "--scope", "project", "--id", "gateway", "--rpm", "30")
|
||||
mustRun(t, db, "keys", "limits", "--scope", "project", "--id", "gateway", "--tools", "jira.*")
|
||||
read := func() store.Limits {
|
||||
s, err := store.Open(context.Background(), db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer s.Close()
|
||||
all, _ := s.AllLimits(context.Background())
|
||||
for _, l := range all {
|
||||
return l
|
||||
}
|
||||
return store.Limits{}
|
||||
}
|
||||
if l := read(); l.RPM == nil || *l.RPM != 30 || len(l.AllowedTools) != 1 {
|
||||
t.Errorf("after a tool grant: rpm %v, tools %v", l.RPM, l.AllowedTools)
|
||||
}
|
||||
mustRun(t, db, "keys", "limits", "--replace", "--scope", "project", "--id", "gateway", "--tpm", "1000")
|
||||
if l := read(); l.RPM != nil || l.AllowedTools != nil || l.TPM == nil {
|
||||
t.Errorf("after --replace: %+v", l)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,7 @@ func Root() *cobra.Command {
|
||||
newBundleCmd(),
|
||||
newSimEngineCmd(),
|
||||
newComfyUIGateCmd(),
|
||||
newStrataServeCmd(),
|
||||
newDoctorCmd(),
|
||||
newNodeCmd(),
|
||||
newSelfUpgradeCmd(),
|
||||
|
||||
+57
-17
@@ -76,6 +76,7 @@ func newServerCmd() *cobra.Command {
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "server",
|
||||
Args: cobra.NoArgs,
|
||||
Short: "Run the router (phase 1; the control plane arrives in phase 3)",
|
||||
Long: "Serves the OpenAI API in front of a set of engine replicas.\n\n" +
|
||||
"In this phase the replica list is static: give it in the config file, or\n" +
|
||||
@@ -138,6 +139,26 @@ func newServerCmd() *cobra.Command {
|
||||
_ = shutdownTracing(sctx)
|
||||
}()
|
||||
|
||||
// The ports before anything else: a second server on this machine,
|
||||
// whose ports are taken, must stop here, not go on to compete for
|
||||
// the lease and lead a cluster no request can reach.
|
||||
apiLn, err := net.Listen("tcp", cfg.Listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listening on %s: %w", cfg.Listen, err)
|
||||
}
|
||||
if cfg.MaxConnections > 0 {
|
||||
// Past this, new connections wait in the kernel's backlog
|
||||
// rather than each taking a goroutine and its buffers.
|
||||
apiLn = netutil.LimitListener(apiLn, cfg.MaxConnections)
|
||||
}
|
||||
adminLn, err := net.Listen("tcp", cfg.AdminListen)
|
||||
if err != nil {
|
||||
_ = apiLn.Close()
|
||||
return fmt.Errorf("listening on %s: %w", cfg.AdminListen, err)
|
||||
}
|
||||
defer apiLn.Close()
|
||||
defer adminLn.Close()
|
||||
|
||||
rt, err := router.New(cfg, log)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -418,6 +439,9 @@ func newServerCmd() *cobra.Command {
|
||||
}
|
||||
// The cluster's settings, each change checked against this
|
||||
// machine's own keys before it is kept.
|
||||
api.SetMCP(st)
|
||||
rt.SetAdminMCP(http.HandlerFunc(api.MCP))
|
||||
api.SetAuth(&adminapi.AuthAdmin{Status: rt.AuthStatus, TestLDAP: rt.TestDirectory, Secrets: st, Log: log})
|
||||
api.SetSettings(&adminapi.Settings{Store: st, LocalKeys: config.LocalKeys(),
|
||||
Check: func(body []byte) error { return checkClusterSettings(configPath, body) }})
|
||||
// And the same document on the portal, for administrators.
|
||||
@@ -543,10 +567,21 @@ func newServerCmd() *cobra.Command {
|
||||
// rather than turning into a broken admin page hours later when
|
||||
// somebody finally opens it.
|
||||
if cfg.Mirror.API != "" {
|
||||
mc, err := mirror.NewClient(cfg.Mirror.API, cfg.Mirror.TLS, cfg.Mirror.Timeout)
|
||||
mirrors := append([]string{cfg.Mirror.API}, cfg.Mirror.Peers...)
|
||||
mc, err := mirror.NewFailoverClient(mirrors, cfg.Mirror.TLS, cfg.Mirror.Timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the model mirror: %w", err)
|
||||
}
|
||||
// Mirrors kept in step: a node is given a model's file on one
|
||||
// that answers while the one the catalog names is down.
|
||||
if len(cfg.Mirror.Peers) > 0 {
|
||||
src, err := mirror.NewSources(mirrors, cfg.Mirror.TLS, log)
|
||||
if err != nil {
|
||||
return fmt.Errorf("the model mirrors: %w", err)
|
||||
}
|
||||
go src.Run(ctx)
|
||||
agentsvc.SetSourceRewrite(src.Rewrite)
|
||||
}
|
||||
rt.SetMirror(mc)
|
||||
if fwPub != nil {
|
||||
fwPub.setMirror(mc)
|
||||
@@ -554,7 +589,7 @@ func newServerCmd() *cobra.Command {
|
||||
// Adapters trained on the nodes are kept there too; sending one
|
||||
// takes longer than the mirror's usual answers.
|
||||
if adminAPI != nil {
|
||||
if up, err := mirror.NewClient(cfg.Mirror.API, cfg.Mirror.TLS, 10*time.Minute); err == nil {
|
||||
if up, err := mirror.NewFailoverClient(mirrors, cfg.Mirror.TLS, 10*time.Minute); err == nil {
|
||||
adminAPI.SetTrainMirror(up)
|
||||
}
|
||||
}
|
||||
@@ -585,6 +620,7 @@ func newServerCmd() *cobra.Command {
|
||||
// A judge a policy names and the catalog lacks is
|
||||
// fetched and served, on the leader.
|
||||
keeper := &judgeKeeper{
|
||||
grace: judgeGrace,
|
||||
library: gate.Library,
|
||||
models: func() []catalog.Model { return admin.Catalog().Models() },
|
||||
serve: serve,
|
||||
@@ -593,6 +629,25 @@ func newServerCmd() *cobra.Command {
|
||||
},
|
||||
gpus: func() []adminapi.ClassUse { return adminAPI.Summary().GPUs },
|
||||
log: log,
|
||||
// An earlier install's judge, moved to today's figures.
|
||||
profiles: catalogSvc,
|
||||
restart: admin.Reconciler().Restart,
|
||||
replicas: func(model string) []string {
|
||||
var ids []string
|
||||
for _, r := range admin.Reconciler().ReadyReplicas() {
|
||||
if r.Model == model {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
},
|
||||
nodeCPUs: func() []int {
|
||||
var out []int
|
||||
for _, n := range admin.Nodes() {
|
||||
out = append(out, n.CPUCount)
|
||||
}
|
||||
return out
|
||||
},
|
||||
}
|
||||
go keeper.run(ctx, 30*time.Second)
|
||||
}
|
||||
@@ -642,21 +697,6 @@ func newServerCmd() *cobra.Command {
|
||||
ReadTimeout: time.Minute,
|
||||
})
|
||||
|
||||
apiLn, err := net.Listen("tcp", cfg.Listen)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listening on %s: %w", cfg.Listen, err)
|
||||
}
|
||||
if cfg.MaxConnections > 0 {
|
||||
// Past this, new connections wait in the kernel's backlog
|
||||
// rather than each taking a goroutine and its buffers.
|
||||
apiLn = netutil.LimitListener(apiLn, cfg.MaxConnections)
|
||||
}
|
||||
adminLn, err := net.Listen("tcp", cfg.AdminListen)
|
||||
if err != nil {
|
||||
_ = apiLn.Close()
|
||||
return fmt.Errorf("listening on %s: %w", cfg.AdminListen, err)
|
||||
}
|
||||
|
||||
// A metrics-only listener, when one is configured. Started after
|
||||
// AdminHandler, which is where the registry it serves is built.
|
||||
var metricsSrv *http.Server
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// `llmgw server check` once ran a whole second server: words after server
|
||||
// that name no subcommand are refused.
|
||||
func TestServerTakesNoWordsItDoesNotKnow(t *testing.T) {
|
||||
cmd := newServerCmd()
|
||||
cmd.SetArgs([]string{"check", "--config", "/nonexistent"})
|
||||
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "unknown command") {
|
||||
t.Errorf("err %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// A second server whose port is taken stops at once, before the router or
|
||||
// any control plane is built.
|
||||
func TestAServerWhosePortIsTakenStopsFirst(t *testing.T) {
|
||||
taken, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer taken.Close()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cmd := newServerCmd()
|
||||
cmd.SetArgs([]string{"--listen", taken.Addr().String(), "--admin-listen", "127.0.0.1:0",
|
||||
"--endpoint", "a=http://127.0.0.1:1=m"})
|
||||
cmd.SilenceUsage, cmd.SilenceErrors = true, true
|
||||
if err := cmd.ExecuteContext(ctx); err == nil || !strings.Contains(err.Error(), "listening on") {
|
||||
t.Errorf("err %v", err)
|
||||
}
|
||||
}
|
||||
+51
-10
@@ -57,10 +57,12 @@ WantedBy=multi-user.target
|
||||
// it again finishes an install that stopped halfway and changes nothing on
|
||||
// one that did not.
|
||||
type serverInit struct {
|
||||
etcDir string // /etc/llmgw
|
||||
stateDir string // /var/lib/llmgw
|
||||
pkiDir string // where the authority lives
|
||||
unitPath string // "" leaves systemd alone
|
||||
// serviceActive says whether llmgw-server runs; nil asks systemctl.
|
||||
serviceActive func() bool
|
||||
etcDir string // /etc/llmgw
|
||||
stateDir string // /var/lib/llmgw
|
||||
pkiDir string // where the authority lives
|
||||
unitPath string // "" leaves systemd alone
|
||||
|
||||
// user owns the state and reads the key; "" means no user is created and
|
||||
// nothing is chowned, which is what a test wants.
|
||||
@@ -80,6 +82,16 @@ type serverInit struct {
|
||||
// setup screen does with the password it was given; when set, patroni
|
||||
// only names them.
|
||||
patroniNodes []pgha.Node
|
||||
// judgeMachines are machines for the guardrails' judges alone, added as
|
||||
// nodes labelled for them; judgeCPUs their cores, once added.
|
||||
judgeMachines []judgeMachine
|
||||
judgeCPUs []int
|
||||
// secondMirror is which machine of the store, 1 or 2, keeps a second
|
||||
// mirror in step with this machine's; 0 is none.
|
||||
secondMirror int
|
||||
// judgeNote is what timing the judge found, said before the next
|
||||
// steps when it is not fine.
|
||||
judgeNote string
|
||||
// noMirror, noNode and noModel turn off the parts of a first install that
|
||||
// make it serve something: a mirror, this machine as a node, and a small
|
||||
// model on it.
|
||||
@@ -234,6 +246,10 @@ func newServerInitCmd() *cobra.Command {
|
||||
f.StringVar(&judgeCats, "judge-categories", strings.Join(defaultJudgeCategories, ","),
|
||||
"what the judge refuses, comma-separated, of: "+strings.Join(judgeCategories, ", "))
|
||||
f.IntVar(&in.judge.Replicas, "judge-replicas", 2, "how many copies of the judge run, each on CPUs")
|
||||
f.IntVar(&in.secondMirror, "second-mirror", 0,
|
||||
"with --patroni, a second mirror on the store's machine 1 or 2, kept in step with this one's (0: none)")
|
||||
f.Var(judgeMachineFlag{&in.judgeMachines}, "judge-node",
|
||||
"user@host of a machine for the guardrails' judges alone: added as a node, and the judges run only there; repeatable")
|
||||
f.DurationVar(&in.judge.Budget, "judge-budget", 5*time.Second, "how long a request waits for the judge")
|
||||
f.StringVar(&in.language, "language", "", "the portal's language for a browser that asks for neither: en or tr")
|
||||
f.BoolVar(&in.signupOpen, "signup-open", false, "let anyone ask for an account on the portal, to be approved by an administrator")
|
||||
@@ -257,6 +273,9 @@ func (in *serverInit) run(ctx context.Context) error {
|
||||
if err := in.checkBundle(); err != nil {
|
||||
return fmt.Errorf("offline bundle: %w", err)
|
||||
}
|
||||
if err := in.checkMemory(); err != nil {
|
||||
return err
|
||||
}
|
||||
steps := []struct {
|
||||
name string
|
||||
fn func(context.Context) error
|
||||
@@ -317,6 +336,11 @@ func (in *serverInit) run(ctx context.Context) error {
|
||||
if err := in.ensureWebhooks(ctx); err != nil {
|
||||
fmt.Fprintf(in.out, " !! webhooks: %v\n", err)
|
||||
}
|
||||
if hasSystemd && len(in.judgeMachines) > 0 {
|
||||
if err := in.ensureJudgeNodes(ctx); err != nil {
|
||||
return fmt.Errorf("judge nodes: %w", err)
|
||||
}
|
||||
}
|
||||
if in.judgeWanted() && in.configJudgesWith(guardModelName) {
|
||||
// Not fatal: without its judge the baseline policy still checks for
|
||||
// secrets and injection, and a judged request is served flagged.
|
||||
@@ -338,6 +362,15 @@ func (in *serverInit) run(ctx context.Context) error {
|
||||
return fmt.Errorf("standby control planes: %w", err)
|
||||
}
|
||||
}
|
||||
if hasSystemd && in.secondMirror > 0 {
|
||||
if err := in.ensureSecondMirror(ctx); err != nil {
|
||||
// Not fatal: the first mirror serves; said, and how to try again.
|
||||
fmt.Fprintf(in.out, " !! second mirror: %v; later: sudo llmgw mirror add-peer <user>@<machine>\n", err)
|
||||
}
|
||||
}
|
||||
if hasSystemd && in.judgeWanted() && in.configJudgesWith(guardModelName) {
|
||||
in.checkJudge(ctx)
|
||||
}
|
||||
in.nextSteps()
|
||||
return nil
|
||||
}
|
||||
@@ -345,12 +378,7 @@ func (in *serverInit) run(ctx context.Context) error {
|
||||
// judgeWanted is whether this install fetches the judge model, and so
|
||||
// whether the baseline policy asks it.
|
||||
func (in *serverInit) judgeWanted() bool {
|
||||
if in.noGuardModel || (in.noMirror && in.mirrorURL == "") || in.unitPath == "" {
|
||||
return false
|
||||
}
|
||||
// Offline, only a judge the bundle carries: a policy asking a judge
|
||||
// that never comes refuses every request.
|
||||
if in.bundle != "" && !in.bundleCarries(guardModelName) {
|
||||
if !in.judgeComes() || in.unitPath == "" {
|
||||
return false
|
||||
}
|
||||
_, err := exec.LookPath("systemctl")
|
||||
@@ -902,6 +930,9 @@ func (in *serverInit) waitHealthy(ctx context.Context) error {
|
||||
}
|
||||
|
||||
func (in *serverInit) nextSteps() {
|
||||
if in.judgeNote != "" {
|
||||
fmt.Fprint(in.out, "\n"+in.judgeNote)
|
||||
}
|
||||
fmt.Fprint(in.out, `
|
||||
The control plane is up. Next:
|
||||
|
||||
@@ -977,3 +1008,13 @@ func virtualInterface(name string) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// judgeComes is whether this setup has a judge to install: one not left
|
||||
// out, a mirror to fetch it from, and, offline, a bundle that carries it —
|
||||
// a policy asking a judge that never comes refuses every request.
|
||||
func (in *serverInit) judgeComes() bool {
|
||||
if in.noGuardModel || (in.noMirror && in.mirrorURL == "") {
|
||||
return false
|
||||
}
|
||||
return in.bundle == "" || in.bundleCarries(guardModelName)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -11,6 +13,8 @@ import (
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
|
||||
)
|
||||
|
||||
// What a first install lets a person choose beyond which parts it has: which
|
||||
@@ -154,6 +158,70 @@ func (s starterSpec) minMemoryGB() int {
|
||||
return max(2, int(math.Ceil(s.SizeGB+1.5)))
|
||||
}
|
||||
|
||||
// servingMemoryMB is what this machine needs, beyond what the control plane
|
||||
// does, for the models it will serve itself: the starter's and the judges'
|
||||
// reservations, and a gigabyte more because the scheduler counts a machine's
|
||||
// memory in whole gigabytes, rounded down — 7.75 GB is 7. Zero when it serves
|
||||
// none of them; what names those it does.
|
||||
//
|
||||
// Asked before anything is installed: the 8 GB machine the guide was first
|
||||
// recorded on finished every step and then had no room for the starter, the
|
||||
// one thing a person asks first.
|
||||
func (in *serverInit) servingMemoryMB(judgesElsewhere bool) (mb int, what string) {
|
||||
if in.noNode {
|
||||
return 0, ""
|
||||
}
|
||||
gb := 0
|
||||
var parts []string
|
||||
if !in.noModel && (!in.noMirror || in.mirrorURL != "") {
|
||||
gb += in.starterModelSpec().minMemoryGB()
|
||||
parts = append(parts, "the starter model")
|
||||
}
|
||||
// Only judges that will be installed: none without a mirror to fetch
|
||||
// them from, or from a bundle that does not carry one.
|
||||
if !judgesElsewhere && in.judgeComes() {
|
||||
n := in.judgeFor().Replicas
|
||||
gb += n * judgeMemoryGB
|
||||
parts = append(parts, fmt.Sprintf("%d judges", n))
|
||||
}
|
||||
if gb == 0 {
|
||||
return 0, ""
|
||||
}
|
||||
return (gb + 1) * 1024, strings.Join(parts, " and ")
|
||||
}
|
||||
|
||||
// checkMemory refuses, before anything is installed, a machine too small for
|
||||
// the models it would serve as well as the control plane.
|
||||
func (in *serverInit) checkMemory() error {
|
||||
if in.unitPath == "" {
|
||||
return nil // a development run, served by nothing
|
||||
}
|
||||
// Run again on a machine already serving — to change --host, say, as
|
||||
// the docs advise — it installs no models it does not already serve. A
|
||||
// first run that stopped half way is checked again: it still would.
|
||||
if in.alreadyServing() {
|
||||
return nil
|
||||
}
|
||||
meminfo, err := os.ReadFile("/proc/meminfo")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
total, ok := pgha.MemTotal(string(meminfo))
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
extra, what := in.servingMemoryMB(len(in.judgeMachines) > 0)
|
||||
if extra == 0 {
|
||||
return nil
|
||||
}
|
||||
have, need := int(total>>20), preflight.Needs[preflight.RoleControl].MemoryMB+extra
|
||||
if have >= need {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("memory: this machine has %d MB; the control plane and %s it would serve need %d. "+
|
||||
"Give it more, or leave them off it: --no-model, --no-guard-model, or --no-node to serve models only on nodes added later", have, what, need)
|
||||
}
|
||||
|
||||
// defaultContext is the context a client may use of the starter.
|
||||
const defaultContext = 4096
|
||||
|
||||
@@ -194,6 +262,9 @@ func (in *serverInit) judgeFor() judgeSpec {
|
||||
}
|
||||
if j.Replicas <= 0 {
|
||||
j.Replicas = 2
|
||||
if in.singleMachine() {
|
||||
j.Replicas = 1
|
||||
}
|
||||
}
|
||||
if j.Budget <= 0 {
|
||||
j.Budget = 5 * time.Second
|
||||
@@ -201,6 +272,14 @@ func (in *serverInit) judgeFor() judgeSpec {
|
||||
return j
|
||||
}
|
||||
|
||||
// singleMachine is an install where this machine is the only node there
|
||||
// will be, as far as init knows: a second judge replica there shares the
|
||||
// same cores and is lost with the same machine, so it buys nothing but
|
||||
// half the speed of each.
|
||||
func (in *serverInit) singleMachine() bool {
|
||||
return !in.noNode && !in.standbys && len(in.patroni) == 0 && len(in.patroniNodes) == 0
|
||||
}
|
||||
|
||||
// The configuration's limits, as init writes them unless told otherwise.
|
||||
const (
|
||||
defaultRPM = 60
|
||||
@@ -314,6 +393,12 @@ func (in *serverInit) initFlags() []string {
|
||||
for _, t := range in.patroni {
|
||||
add("--patroni", t)
|
||||
}
|
||||
for _, m := range in.judgeMachines {
|
||||
add("--judge-node", m.target)
|
||||
}
|
||||
if in.secondMirror > 0 {
|
||||
add("--second-mirror", fmt.Sprint(in.secondMirror))
|
||||
}
|
||||
switch {
|
||||
case in.mirrorURL != "":
|
||||
add("--mirror", in.mirrorURL)
|
||||
@@ -346,7 +431,10 @@ func (in *serverInit) initFlags() []string {
|
||||
if in.noGuardModel {
|
||||
add("--no-guard-model")
|
||||
} else {
|
||||
j, d := in.judgeFor(), (&serverInit{}).judgeFor()
|
||||
// Against the defaults for an install of the same shape: one judge
|
||||
// on one machine, two otherwise.
|
||||
shape := serverInit{noNode: in.noNode, standbys: in.standbys, patroni: in.patroni, patroniNodes: in.patroniNodes}
|
||||
j, d := in.judgeFor(), shape.judgeFor()
|
||||
if j.Fail != d.Fail {
|
||||
add("--judge-fail", j.Fail)
|
||||
}
|
||||
@@ -395,3 +483,15 @@ func shellWord(s string) string {
|
||||
}
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
|
||||
// alreadyServing reports whether this machine's gateway is set up and
|
||||
// running: a server.yaml, and its unit active.
|
||||
func (in *serverInit) alreadyServing() bool {
|
||||
if _, err := os.Stat(in.configPath()); err != nil {
|
||||
return false
|
||||
}
|
||||
if in.serviceActive != nil {
|
||||
return in.serviceActive()
|
||||
}
|
||||
return exec.Command("systemctl", "is-active", "--quiet", "llmgw-server").Run() == nil
|
||||
}
|
||||
@@ -183,12 +183,17 @@ func TestTheJudgesAndTheStarterShareTheCPUsRatherThanEachTakingAll(t *testing.T)
|
||||
in serverInit
|
||||
judge, starter int
|
||||
}{
|
||||
{serverInit{cpus: 4}, 1, 2},
|
||||
{serverInit{cpus: 8}, 2, 4},
|
||||
{serverInit{cpus: 32}, 2, 8},
|
||||
{serverInit{cpus: 2}, 1, 0}, // nothing left: the starter shares rather than fits nowhere
|
||||
// One machine: one judge, on up to four cores; the starter has the rest.
|
||||
{serverInit{cpus: 4}, 4, 0},
|
||||
{serverInit{cpus: 8}, 4, 4},
|
||||
{serverInit{cpus: 32}, 4, 8},
|
||||
{serverInit{cpus: 2}, 2, 0},
|
||||
{serverInit{cpus: 1}, 0, 0},
|
||||
{serverInit{cpus: 4, noGuardModel: true}, 1, 4},
|
||||
{serverInit{cpus: 4, noGuardModel: true}, judgeCores, 4}, // no judge: all for the starter
|
||||
// Two asked for on one machine: two cores each, one each where two
|
||||
// would not fit both.
|
||||
{serverInit{cpus: 8, judge: judgeSpec{Replicas: 2}}, 2, 4},
|
||||
{serverInit{cpus: 2, judge: judgeSpec{Replicas: 2}}, 1, 0},
|
||||
{serverInit{cpus: 8, judge: judgeSpec{Replicas: 3}}, 2, 2},
|
||||
{serverInit{cpus: 4, noNode: true}, 2, 0}, // on nodes not known yet
|
||||
} {
|
||||
@@ -197,12 +202,40 @@ func TestTheJudgesAndTheStarterShareTheCPUsRatherThanEachTakingAll(t *testing.T)
|
||||
t.Errorf("%+v: judge %d starter %d, want %d %d", c.in, got.judge, got.starter, c.judge, c.starter)
|
||||
}
|
||||
}
|
||||
m := guardModel("http://x/g.gguf", 2, 1)
|
||||
if p := m.Profiles[0]; p.CPUCores != 1 || p.MinMemoryGB != 4 {
|
||||
t.Errorf("judge profile: %d cores, %d GB", p.CPUCores, p.MinMemoryGB)
|
||||
m := guardModel("http://x/g.gguf", 2, 1, nil)
|
||||
if p := m.Profiles[0]; p.CPUCores != 1 || p.MinMemoryGB != 2 || !slices.Contains(p.Args, "--cache-ram") {
|
||||
t.Errorf("judge profile: %d cores, %d GB, args %v", p.CPUCores, p.MinMemoryGB, p.Args)
|
||||
}
|
||||
s := starterModel(starterChoices[0], defaultContext, 2, "http://x/q.gguf", []string{"6000-24g"}, true)
|
||||
if cpu := s.Profiles[len(s.Profiles)-1]; cpu.Name != "cpu" || cpu.CPUCores != 2 || s.Profiles[0].CPUCores != 0 {
|
||||
t.Errorf("starter: GPU profile %d cores, CPU %+v", s.Profiles[0].CPUCores, cpu)
|
||||
}
|
||||
}
|
||||
|
||||
// A second judge on the same machine shares its cores and is lost with it:
|
||||
// one machine has one, more machines two.
|
||||
func TestOneMachineHasOneJudgeAndMoreHaveTwo(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
in serverInit
|
||||
want int
|
||||
}{
|
||||
{serverInit{}, 1},
|
||||
{serverInit{noNode: true}, 2},
|
||||
{serverInit{patroni: []string{"a@db1", "b@db2"}}, 2},
|
||||
{serverInit{standbys: true}, 2},
|
||||
{serverInit{judge: judgeSpec{Replicas: 3}}, 3},
|
||||
} {
|
||||
if got := c.in.judgeFor().Replicas; got != c.want {
|
||||
t.Errorf("%+v: %d judges, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
// The defaults are left out of the command; a choice against them is in.
|
||||
ha := serverInit{noNode: true, patroni: []string{"a@db1", "b@db2"}, judge: judgeSpec{Replicas: 2}}
|
||||
if f := strings.Join(ha.initFlags(), " "); strings.Contains(f, "--judge-replicas") {
|
||||
t.Errorf("two judges on three machines named: %s", f)
|
||||
}
|
||||
one := serverInit{judge: judgeSpec{Replicas: 2}}
|
||||
if f := strings.Join(one.initFlags(), " "); !strings.Contains(f, "--judge-replicas 2") {
|
||||
t.Errorf("two judges on one machine not named: %s", f)
|
||||
}
|
||||
}
|
||||
@@ -123,10 +123,10 @@ func (in *serverInit) writeUnit(path, body string) (bool, error) {
|
||||
func (in *serverInit) mirrorUnit() string {
|
||||
base := "http://" + net.JoinHostPort(in.reachableAddr(), "8081")
|
||||
if in.standbys {
|
||||
return mirrorUnitFor(in.mirrorRoot(), ":8082", base, in.mirrorAllow(),
|
||||
return mirrorUnitFor(in.mirrorRoot(), ":8082", base, in.mirrorAllow(), nil,
|
||||
"'llmgw server init'. Bytes on :8081 for every node; the\n# management API for the control planes only, whichever leads.")
|
||||
}
|
||||
return mirrorUnitFor(in.mirrorRoot(), mirrorAPI, base, nil,
|
||||
return mirrorUnitFor(in.mirrorRoot(), mirrorAPI, base, nil, nil,
|
||||
"'llmgw server init'. Bytes on :8081 for every node; the\n# management API on loopback only, for the control plane beside it.")
|
||||
}
|
||||
|
||||
@@ -154,11 +154,14 @@ func (in *serverInit) mirrorAllow() []string {
|
||||
|
||||
// mirrorUnitFor is a mirror's systemd unit. allow is who, besides the
|
||||
// machine itself, may use the management API.
|
||||
func mirrorUnitFor(dir, apiListen, base string, allow []string, about string) string {
|
||||
func mirrorUnitFor(dir, apiListen, base string, allow, peers []string, about string) string {
|
||||
exec := "/usr/local/bin/llmgw mirror serve --dir " + dir + " --listen :8081 --api-listen " + apiListen + " --base " + base
|
||||
for _, a := range allow {
|
||||
exec += " --api-allow " + a
|
||||
}
|
||||
for _, p := range peers {
|
||||
exec += " --peer " + p
|
||||
}
|
||||
return `[Unit]
|
||||
Description=llmgw model mirror
|
||||
After=network-online.target
|
||||
@@ -485,7 +488,7 @@ func starterModel(spec starterSpec, ctx, cores int, source string, gpuClasses []
|
||||
|
||||
// guardModel is the judge's catalog entry: CPU only, so it takes no card from
|
||||
// the models people use, on whichever node has room; replicas copies of it.
|
||||
func guardModel(source string, replicas, cores int) catalog.Model {
|
||||
func guardModel(source string, replicas, cores int, selector map[string]string) catalog.Model {
|
||||
return catalog.Model{
|
||||
Name: guardModelName,
|
||||
Description: "The guardrails' judge: reads prompts and answers for unsafe content. Not offered to clients.",
|
||||
@@ -496,10 +499,15 @@ func guardModel(source string, replicas, cores int) catalog.Model {
|
||||
// judge that is not there refuses everything.
|
||||
Scaling: catalog.Scaling{Min: replicas, Max: replicas},
|
||||
Profiles: []catalog.Profile{{
|
||||
// It uses about 3.2 GB with its context: reserving 2 filled nodes
|
||||
// past their memory.
|
||||
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: 4, CPUCores: cores,
|
||||
Args: []string{"--ctx-size", "8192"},
|
||||
// About 1.6 GB with its context, which stays so with no prompt
|
||||
// cache: llama.cpp keeps up to 8 GB of prompts by default, and a
|
||||
// judge's, each a different text, are not asked again. With the
|
||||
// cache one grew to 3.2 GB over a day of traffic; reserving 4 for
|
||||
// that left a 7 GB machine room for only one of the two.
|
||||
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: judgeMemoryGB, CPUCores: cores,
|
||||
Args: []string{"--ctx-size", "8192", "--cache-ram", "0"},
|
||||
// The judge machines, when the install was given some.
|
||||
NodeSelector: selector,
|
||||
}},
|
||||
}
|
||||
}
|
||||
@@ -521,7 +529,8 @@ func (in *serverInit) ensureStarter(ctx context.Context) error {
|
||||
func (in *serverInit) ensureGuardModel(ctx context.Context) error {
|
||||
return in.ensureServed(ctx, guardModelName, guardModelSource, guardModelVersion, false,
|
||||
func(url string, _ []string) catalog.Model {
|
||||
return guardModel(url, in.judgeFor().Replicas, in.cpuPlan().judge)
|
||||
cores, selector := in.judgePlacement()
|
||||
return guardModel(url, in.judgeFor().Replicas, cores, selector)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -716,14 +725,16 @@ type cpuSplit struct{ judge, starter int }
|
||||
// the starter on one 4-core control plane all ran on all four: under load
|
||||
// most judgments missed their budget and the requests were refused.
|
||||
//
|
||||
// When this machine is the node, by its own CPUs: a core a judge under 8,
|
||||
// two from 8, and the starter what is left, up to 8, or no reservation at
|
||||
// all when nothing is, rather than a starter that fits nowhere. Otherwise
|
||||
// two a judge, on whichever node takes it, and the starter, on a node not
|
||||
// known yet, none.
|
||||
// A judge needs cores: a judgment of about 280 tokens took 8.0 s on one,
|
||||
// 4.2 s on two and 2.3 s on four, against a 5 s budget. On one machine its
|
||||
// one replica gets up to four; where there are two, two each (one each only
|
||||
// where two would not fit both). The starter has what is left, up to 8, or
|
||||
// no reservation at all when nothing is, rather than a starter that fits
|
||||
// nowhere. A machine that is not the node reserves two a judge on whichever
|
||||
// node takes it, and the starter, on a node not known yet, none.
|
||||
func (in *serverInit) cpuPlan() cpuSplit {
|
||||
if in.noNode {
|
||||
return cpuSplit{judge: 2}
|
||||
return cpuSplit{judge: judgeCores}
|
||||
}
|
||||
n := in.cpus
|
||||
if n <= 0 {
|
||||
@@ -736,13 +747,23 @@ func (in *serverInit) cpuPlan() cpuSplit {
|
||||
if !in.noGuardModel {
|
||||
judges = in.judgeFor().Replicas
|
||||
}
|
||||
judge := 1
|
||||
if n >= 8 {
|
||||
judge = 2
|
||||
}
|
||||
left := n - judges*judge
|
||||
if left < 1 {
|
||||
left = 0
|
||||
judge := judgeCores
|
||||
switch {
|
||||
case judges == 1:
|
||||
judge = min(n, judgeCoresAlone)
|
||||
case judges > 0 && judges*judge > n:
|
||||
judge = max(1, n/judges)
|
||||
}
|
||||
left := max(n-judges*judge, 0)
|
||||
return cpuSplit{judge: judge, starter: min(left, 8)}
|
||||
}
|
||||
|
||||
// judgeCores and judgeMemoryGB are what a judge replica needs: two cores
|
||||
// for a judgment within its budget, and 2 GB for its 1.6 with no prompt
|
||||
// cache. judgeCoresAlone is what the one judge of a single machine takes,
|
||||
// past which a model this small hardly gets faster.
|
||||
const (
|
||||
judgeCores = 2
|
||||
judgeCoresAlone = 4
|
||||
judgeMemoryGB = 2
|
||||
)
|
||||
@@ -171,7 +171,7 @@ func TestTheInstalledConfigurationIsHardenedAndLoads(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestTheGuardModelIsAValidJudge(t *testing.T) {
|
||||
m := guardModel("http://10.0.0.5:8081/qwen3guard-0.6b/guard/g.gguf", 2, 2)
|
||||
m := guardModel("http://10.0.0.5:8081/qwen3guard-0.6b/guard/g.gguf", 2, 2, nil)
|
||||
c, err := catalog.New([]catalog.Model{m})
|
||||
if err != nil {
|
||||
t.Fatalf("not a valid catalog entry: %v", err)
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/placement"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||
)
|
||||
|
||||
// Machines of the judges' own. Under load on a 4-core control plane that was
|
||||
// also a node, 38 of 60 requests were refused for want of a judgment; with the
|
||||
// judges on a node of their own the same test refused 2. A judge machine is
|
||||
// added as a node carrying judgeRoleLabel, and the judge's profile selects
|
||||
// that label, so nothing else is placed beside it and it on nothing else.
|
||||
|
||||
// judgeRoleLabel marks a node as the judges'.
|
||||
const (
|
||||
judgeRoleLabel = placement.RoleLabel
|
||||
judgeRoleValue = "judge"
|
||||
)
|
||||
|
||||
// judgeMachine is a machine for the judges: user@host, and the connection the
|
||||
// setup screen already made to it, when it made one.
|
||||
type judgeMachine struct {
|
||||
target string
|
||||
remote onboard.Remote
|
||||
}
|
||||
|
||||
// ensureJudgeNodes adds each judge machine as a node labelled for the
|
||||
// judges, and says how many cores the smallest of them has.
|
||||
func (in *serverInit) ensureJudgeNodes(ctx context.Context) error {
|
||||
if len(in.judgeMachines) == 0 {
|
||||
return nil
|
||||
}
|
||||
na := nodeAdd{serverCfg: in.configPath(), plain: true}
|
||||
na.opts.PKI = in.pkiDir
|
||||
na.opts.NoFirewall = in.noFirewall
|
||||
if err := na.prepare(); err != nil {
|
||||
return err
|
||||
}
|
||||
in.judgeCPUs = nil
|
||||
for _, jm := range in.judgeMachines {
|
||||
o := na.target(jm.target)
|
||||
fmt.Fprintf(in.out, " .. adding %s as a node for the judges\n", o.Addr)
|
||||
r := onboard.New(o, nil)
|
||||
if jm.remote != nil {
|
||||
r.Remote = jm.remote
|
||||
}
|
||||
res, err := r.Run(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: %w", o.Addr, err)
|
||||
}
|
||||
if err := na.watch(ctx, res.Node, func(onboard.Event) {}); err != nil && err != errNoAdmin {
|
||||
return fmt.Errorf("%s: %w", res.Node, err)
|
||||
}
|
||||
if out, err := r.Remote.Run(ctx, "nproc", nil); err == nil {
|
||||
if n, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
|
||||
in.judgeCPUs = append(in.judgeCPUs, n)
|
||||
}
|
||||
}
|
||||
if _, err := changeStoredSettings(in.configPath(), "a node for the judges, from init", func(doc *settings.Doc) error {
|
||||
return doc.SetYAML([]string{"agents", "nodes", res.Node, "labels", judgeRoleLabel}, judgeRoleValue)
|
||||
}); err != nil {
|
||||
return fmt.Errorf("labelling %s for the judges: %w", res.Node, err)
|
||||
}
|
||||
in.done("%s is a node for the judges alone (%s: %s)", res.Node, judgeRoleLabel, judgeRoleValue)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// judgePlacement is the judge's cores and where it may run: on the judge
|
||||
// machines when there are some, by init's rule over their cores; otherwise
|
||||
// on any node, with this machine's plan.
|
||||
func (in *serverInit) judgePlacement() (cores int, selector map[string]string) {
|
||||
if len(in.judgeMachines) == 0 {
|
||||
return in.cpuPlan().judge, nil
|
||||
}
|
||||
return refitCores(in.judgeFor().Replicas, in.judgeCPUs), map[string]string{judgeRoleLabel: judgeRoleValue}
|
||||
}
|
||||
|
||||
// judgeMachineFlag is --judge-node, given again for each machine.
|
||||
type judgeMachineFlag struct{ to *[]judgeMachine }
|
||||
|
||||
func (f judgeMachineFlag) String() string {
|
||||
if f.to == nil {
|
||||
return ""
|
||||
}
|
||||
var out []string
|
||||
for _, m := range *f.to {
|
||||
out = append(out, m.target)
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func (f judgeMachineFlag) Set(v string) error {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return fmt.Errorf("--judge-node wants user@host")
|
||||
}
|
||||
*f.to = append(*f.to, judgeMachine{target: strings.TrimSpace(v)})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (judgeMachineFlag) Type() string { return "user@host" }
|
||||
|
||||
// judgeReadyWait is how long init waits for its judge to load before timing
|
||||
// it: the weights come from the mirror, and llama.cpp loads in seconds.
|
||||
const judgeReadyWait = 15 * time.Minute
|
||||
|
||||
// checkJudge times the judge once it is up, and keeps what it found for the
|
||||
// closing text: a machine too slow for its judge is said before its first
|
||||
// refused request, not after. The budget is not changed without asking.
|
||||
func (in *serverInit) checkJudge(ctx context.Context) {
|
||||
c := in.adminAPI()
|
||||
deadline := time.Now().Add(judgeReadyWait)
|
||||
for {
|
||||
var judges []adminapi.JudgeView
|
||||
if err := c.get(ctx, "/api/v1/guard/judges", &judges); err == nil {
|
||||
ready := false
|
||||
for _, j := range judges {
|
||||
ready = ready || (j.Model == guardModelName && j.Ready > 0)
|
||||
}
|
||||
if ready {
|
||||
break
|
||||
}
|
||||
}
|
||||
if time.Now().After(deadline) || ctx.Err() != nil {
|
||||
in.judgeNote = " !! the judge was not up to be timed; once it is: llmgw ctl guard judge check\n"
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Second)
|
||||
}
|
||||
fmt.Fprintf(in.out, " .. timing the judge\n")
|
||||
v, err := checkJudge(ctx, c, guardModelName, 0)
|
||||
if err != nil {
|
||||
in.judgeNote = " !! the judge could not be timed (" + err.Error() + "); later: llmgw ctl guard judge check\n"
|
||||
return
|
||||
}
|
||||
if v.Verdict == "ok" {
|
||||
in.done("the judge reads a prompt in %.1f s, of the %.1f s a request waits; about %.1f requests a second",
|
||||
float64(v.SlowestMs)/1000, float64(v.BudgetMs)/1000, v.PerSecond/2)
|
||||
return
|
||||
}
|
||||
in.judgeNote = "The judge is " + v.Verdict + " on this cluster:\n" + indent(judgeCheckText(v), " ") + "\n"
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestJudgesGoOnTheirOwnMachinesWhenTheInstallHasSome(t *testing.T) {
|
||||
in := newDefaultServerInit()
|
||||
if _, sel := in.judgePlacement(); sel != nil {
|
||||
t.Errorf("no judge machines, yet a selector: %v", sel)
|
||||
}
|
||||
if err := (judgeMachineFlag{&in.judgeMachines}).Set("ubuntu@10.0.0.21"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = (judgeMachineFlag{&in.judgeMachines}).Set("ubuntu@10.0.0.22")
|
||||
in.judge.Replicas, in.judgeCPUs = 2, []int{4, 4}
|
||||
cores, sel := in.judgePlacement()
|
||||
if sel[judgeRoleLabel] != judgeRoleValue || cores != refitCores(2, []int{4, 4}) {
|
||||
t.Errorf("cores %d, selector %v", cores, sel)
|
||||
}
|
||||
m := guardModel("http://x/g.gguf", 2, cores, sel)
|
||||
if m.Profiles[0].NodeSelector[judgeRoleLabel] != judgeRoleValue {
|
||||
t.Errorf("the judge's profile does not select its machines: %+v", m.Profiles[0])
|
||||
}
|
||||
// The same from a script.
|
||||
flags := strings.Join(in.initFlags(), " ")
|
||||
if !strings.Contains(flags, "--judge-node ubuntu@10.0.0.21") || !strings.Contains(flags, "--judge-node ubuntu@10.0.0.22") {
|
||||
t.Errorf("flags %s", flags)
|
||||
}
|
||||
if err := (judgeMachineFlag{&in.judgeMachines}).Set(" "); err == nil {
|
||||
t.Error("an empty --judge-node was taken")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSetupAsksForJudgeMachinesWhenChosen(t *testing.T) {
|
||||
m := newSetupModel("ctl", []string{"10.0.0.5"})
|
||||
m.choice, m.page = 1, pMachines
|
||||
m.mfocus = m.shown() * mfCount // the judges' choice
|
||||
m = press(m, "right").(setupModel)
|
||||
if m.judges != 1 || m.shown() != 3 || m.mfocus != 3*mfCount {
|
||||
t.Fatalf("judges %d, shown %d, focus %d", m.judges, m.shown(), m.mfocus)
|
||||
}
|
||||
if v := m.machinesView(); !strings.Contains(v, "Judge machine 1") || !strings.Contains(v, "Check the 4 machines") {
|
||||
t.Errorf("the page lacks the judge machine:\n%s", v)
|
||||
}
|
||||
for i, addr := range []string{"10.0.0.11", "10.0.0.12", ""} {
|
||||
m.db[i].addr.SetValue(addr)
|
||||
m.db[i].user.SetValue("ubuntu")
|
||||
m.db[i].auth = authAgent
|
||||
}
|
||||
if err := m.validateMachines(); !strings.Contains(err, "judge machine 1 has no address") {
|
||||
t.Errorf("err %q", err)
|
||||
}
|
||||
m.db[2].addr.SetValue("10.0.0.21")
|
||||
if err := m.validateMachines(); err != "" {
|
||||
t.Errorf("err %q", err)
|
||||
}
|
||||
if cmd := m.command(); !strings.Contains(cmd, "--judge-node ubuntu@10.0.0.21") || strings.Contains(cmd, "--patroni ubuntu@10.0.0.21") {
|
||||
t.Errorf("command %s", cmd)
|
||||
}
|
||||
// None again: the judge machine is no longer asked for.
|
||||
m = press(m, "left").(setupModel)
|
||||
if m.judges != 0 || strings.Contains(m.machinesView(), "Judge machine 1") {
|
||||
t.Errorf("judges %d after choosing none", m.judges)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
|
||||
)
|
||||
|
||||
func TestTheMemoryAMachineNeedsCountsWhatItServes(t *testing.T) {
|
||||
in := &serverInit{}
|
||||
mb, what := in.servingMemoryMB(false)
|
||||
need := preflight.Needs[preflight.RoleControl].MemoryMB + mb
|
||||
if what == "" || need <= 7941 || need > 11961 {
|
||||
t.Errorf("node, starter and judges here: need %d MB (%q); an 8 GB machine must fail and a 12 GB one pass", need, what)
|
||||
}
|
||||
// Judges on machines of their own take their share off this one.
|
||||
if mb2, _ := in.servingMemoryMB(true); mb2 >= mb {
|
||||
t.Errorf("with judge machines %d MB, without %d", mb2, mb)
|
||||
}
|
||||
// A control plane that serves nothing needs only its own.
|
||||
if mb, what := (&serverInit{noNode: true}).servingMemoryMB(false); mb != 0 || what != "" {
|
||||
t.Errorf("no node: %d %q", mb, what)
|
||||
}
|
||||
if mb, _ := (&serverInit{noModel: true, noGuardModel: true}).servingMemoryMB(false); mb != 0 {
|
||||
t.Errorf("nothing served: %d", mb)
|
||||
}
|
||||
// Without a mirror no judge is fetched, so none is counted.
|
||||
if mb, what := (&serverInit{noMirror: true}).servingMemoryMB(false); mb != 0 {
|
||||
t.Errorf("no mirror, nothing to fetch: %d %q", mb, what)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupRunAgainOnAMachineAlreadySetUpIsNotRefusedItsMemory(t *testing.T) {
|
||||
// The docs fix a certificate with server init --host on a machine set up
|
||||
// before the check: it installs nothing it does not already serve.
|
||||
dir := t.TempDir()
|
||||
cfg := dir + "/server.yaml"
|
||||
if err := os.WriteFile(cfg, []byte("listen: :8080\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := &serverInit{unitPath: dir + "/llmgw-server.service", etcDir: dir, serviceActive: func() bool { return true }}
|
||||
if err := in.checkMemory(); err != nil {
|
||||
t.Errorf("refused: %v", err)
|
||||
}
|
||||
// A first run that stopped half way left server.yaml and serves nothing:
|
||||
// it still would install the models, and is checked.
|
||||
in.serviceActive = func() bool { return false }
|
||||
if in.alreadyServing() {
|
||||
t.Error("a machine whose gateway is not running was taken for one serving")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/strata"
|
||||
)
|
||||
|
||||
func newStrataServeCmd() *cobra.Command {
|
||||
var cfg strata.Config
|
||||
cmd := &cobra.Command{
|
||||
Use: "strata-serve [flags] [-- setup.py flags]",
|
||||
Short: "Run Strata as an engine: prepare the model, then serve it (started by the agent)",
|
||||
Hidden: true,
|
||||
Long: "Started by the node agent for a replica whose engine is strata. Prepares the\n" +
|
||||
"model from the fetched GGUF files with Strata's setup, which skips what it has\n" +
|
||||
"done before, then becomes Strata's server on the replica's address. The key is\n" +
|
||||
"read from " + strata.KeyEnv + ".",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
cfg.Args = args
|
||||
cfg.Log = logger()
|
||||
return strata.Run(ctx, cfg)
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&cfg.Listen, "listen", "", "the replica's address")
|
||||
f.StringVar(&cfg.Model, "model", "", "the model's name, for /v1/models")
|
||||
f.StringVar(&cfg.Weights, "weights", "", "the fetched GGUF files' directory")
|
||||
f.StringVar(&cfg.State, "state", "", "the replica's own directory")
|
||||
f.StringVar(&cfg.Data, "data", "", "the node's directory for prepared models, shared by its replicas")
|
||||
f.StringVar(&cfg.Python, "python", "python3", "the interpreter of Strata's environment")
|
||||
f.StringVar(&cfg.Dir, "strata", "", "Strata's directory, where setup.py is")
|
||||
f.StringVar(&cfg.Engine, "engine", "", "Strata's engine directory, where the strata binary is")
|
||||
for _, n := range []string{"listen", "weights", "state", "data", "strata", "engine"} {
|
||||
_ = cmd.MarkFlagRequired(n)
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -332,6 +332,30 @@ func (m consoleModel) modelMenu(md adminapi.ModelView) *menuDialog {
|
||||
}})
|
||||
return nil
|
||||
}},
|
||||
{"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.",
|
||||
fields: []formField{
|
||||
textF("tokens", strconv.Itoa(md.ContextLength), "a request's prompt and answer together"),
|
||||
textF("slots", "", "llama.cpp: requests at once; empty keeps it"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
tokens, err := strconv.Atoi(v[0])
|
||||
if err != nil || tokens < 256 {
|
||||
return nil, fmt.Errorf("the context is a whole number of tokens, at least 256")
|
||||
}
|
||||
slots := 0
|
||||
if v[1] != "" {
|
||||
if slots, err = strconv.Atoi(v[1]); 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
|
||||
}})
|
||||
return nil
|
||||
}},
|
||||
{"Resources…", func(m *consoleModel) tea.Cmd {
|
||||
profiles := md.Profiles
|
||||
if len(profiles) == 0 {
|
||||
@@ -898,3 +922,24 @@ func locationRequest(n adminapi.NodeView, vals []string) adminapi.NodeLabelsRequ
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
// contextSays is a context change in a sentence or two.
|
||||
func contextSays(raw json.RawMessage) string {
|
||||
var v modelserve.ContextChange
|
||||
if json.Unmarshal(raw, &v) != nil {
|
||||
return "Done."
|
||||
}
|
||||
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 != "" {
|
||||
s += "\n" + p.Profile + ": " + p.After
|
||||
}
|
||||
}
|
||||
if v.Note != "" {
|
||||
s += "\n" + v.Note
|
||||
}
|
||||
if v.Restarted > 0 {
|
||||
s += fmt.Sprintf("\n%d replica(s) started again on it, one by one.", v.Restarted)
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -62,6 +62,7 @@ type installForm struct {
|
||||
admin, hosts, language, signup *setupField
|
||||
storeKind *setupField
|
||||
mirrorWhere, mirrorDirF, mirrorURL *setupField
|
||||
secondMirror *setupField
|
||||
starterPick, starterSource, starterCtx *setupField
|
||||
judgeFail, judgeCopies, judgeBudget *setupField
|
||||
judgeCats []*setupField
|
||||
@@ -181,18 +182,26 @@ func newInstallForm(in *serverInit, host string, found []string, ha bool) *insta
|
||||
f.mirrorDirF.Show = func() bool { return f.mirrorWhere.Choice == 0 }
|
||||
f.mirrorURL = textSetupField("Its management API", in.mirrorURL, "http://10.0.0.20:8082", "")
|
||||
f.mirrorURL.Show = func() bool { return f.mirrorWhere.Choice == 1 }
|
||||
f.secondMirror = choiceField("A second mirror", []string{"none", "on database machine 1", "on database machine 2"},
|
||||
in.secondMirror, "Kept in step with this one, each copying what the other has: while either machine is down, "+
|
||||
"models are still added and fetched. It needs room for every model, as this one does.")
|
||||
f.secondMirror.Show = func() bool { return ha && f.mirrorWhere.Choice == 0 }
|
||||
f.mirror = &installPart{
|
||||
title: "Model mirror", on: !in.noMirror || in.mirrorURL != "",
|
||||
about: "Downloads each model once, from Hugging Face or wherever it comes from, and serves the files to " +
|
||||
"every node on the network, so ten nodes do not download the same 40 GB ten times. " +
|
||||
"The starter model, the judge and an offline bundle go into it.",
|
||||
fields: []*setupField{f.mirrorWhere, f.mirrorDirF, f.mirrorURL},
|
||||
fields: []*setupField{f.mirrorWhere, f.mirrorDirF, f.mirrorURL, f.secondMirror},
|
||||
off: "each model's files must already be on a mirror somewhere; no starter model, no judge",
|
||||
summary: func() string {
|
||||
if f.mirrorWhere.Choice == 1 {
|
||||
return "at " + orText(f.mirrorURL.Value(), "(an address)")
|
||||
}
|
||||
return "on this machine, in " + orText(f.mirrorDirF.Value(), mirrorDir)
|
||||
s := "on this machine, in " + orText(f.mirrorDirF.Value(), mirrorDir)
|
||||
if ha && f.secondMirror.Choice > 0 {
|
||||
s += "; a second " + f.secondMirror.Options[f.secondMirror.Choice]
|
||||
}
|
||||
return s
|
||||
},
|
||||
}
|
||||
|
||||
@@ -282,7 +291,7 @@ func newInstallForm(in *serverInit, host string, found []string, ha bool) *insta
|
||||
"The request is served unjudged and marked so in the logs. Nothing is refused for the judge's sake.",
|
||||
}
|
||||
f.judgeCopies = textSetupField("Copies", strconv.Itoa(j.Replicas), "2",
|
||||
"Each takes about 1 GB and two CPU threads on whichever node has room.")
|
||||
"On one machine one copy, on up to four cores; with more machines two, two cores each, on different nodes. Each reserves 2 GB.")
|
||||
f.judgeBudget = textSetupField("Waits at most", durationYAML(j.Budget), "5s",
|
||||
"A short text is judged in about two seconds on CPUs.")
|
||||
judgeFields := []*setupField{f.judgeFail, f.judgeCopies, f.judgeBudget, headingField("Refuses")}
|
||||
@@ -636,6 +645,10 @@ func (f *installForm) apply(in *serverInit) error {
|
||||
in.postgres = !f.ha && f.storeKind.Choice == 1
|
||||
in.noMirror = !f.mirror.on || mirrorURL != ""
|
||||
in.mirrorURL, in.mirrorPath = mirrorURL, mirrorPath
|
||||
in.secondMirror = 0
|
||||
if f.ha && f.mirror.on && f.mirrorWhere.Choice == 0 {
|
||||
in.secondMirror = f.secondMirror.Choice
|
||||
}
|
||||
in.noNode = !f.node.on
|
||||
in.noModel = !f.starter.on
|
||||
in.starter, in.starterCtx = starter, ctx
|
||||
|
||||
+63
-15
@@ -46,6 +46,7 @@ const (
|
||||
cQuotas
|
||||
cGuard
|
||||
cKB
|
||||
cMCP
|
||||
cFederation
|
||||
cMirror
|
||||
cJobs
|
||||
@@ -53,6 +54,7 @@ const (
|
||||
cAccounts
|
||||
cKeys
|
||||
cOrgs
|
||||
cSignIn
|
||||
cAudit
|
||||
cManual
|
||||
cSettings
|
||||
@@ -69,7 +71,7 @@ const (
|
||||
cCount
|
||||
)
|
||||
|
||||
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Guardrails", "Knowledge", "Federation", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Audit", "Manual",
|
||||
var consoleLabels = [cCount]string{"Overview", "Monitor", "Nodes", "GPUs", "Models", "Replicas", "Usage", "Quotas", "Guardrails", "Knowledge", "MCP servers", "Federation", "Mirror", "Jobs", "Logs", "Accounts", "API keys", "Organizations", "Sign-in", "Audit", "Manual",
|
||||
"server.yaml", "Add a node", "Add a control plane", "Replicate the store", "Serve a model", "Serve the guardrails' judge", "Upgrade the cluster",
|
||||
"Restart all replicas", "Back up now", "Wipe this machine", "Leave"}
|
||||
|
||||
@@ -140,8 +142,8 @@ type consoleModel struct {
|
||||
detail bool
|
||||
scroll int
|
||||
|
||||
// node is the console of a node: its agent.yaml and nothing to watch,
|
||||
// since the cluster is watched from the control plane.
|
||||
// node is the console of a node: what runs on the machine and its
|
||||
// agent.yaml, since the cluster is watched from the control plane.
|
||||
node bool
|
||||
|
||||
// The settings.
|
||||
@@ -181,7 +183,9 @@ type consoleModel struct {
|
||||
quotas []adminapi.QuotaView
|
||||
quotasErr error
|
||||
guard guardState
|
||||
signIn signInState
|
||||
kb kbState
|
||||
mcp mcpState
|
||||
|
||||
st *storeHolder
|
||||
people peopleData
|
||||
@@ -190,6 +194,11 @@ type consoleModel struct {
|
||||
docs docViewer
|
||||
mon monitorState
|
||||
det monitorState
|
||||
|
||||
// nodeOv is a node's overview as last read; nodeBusy says a reading is
|
||||
// under way.
|
||||
nodeOv nodeOverview
|
||||
nodeBusy bool
|
||||
}
|
||||
|
||||
var usageGroupings = []string{"team", "project", "org"}
|
||||
@@ -198,9 +207,6 @@ func newConsoleModel(addr, file string) consoleModel {
|
||||
m := consoleModel{client: newCtlClient(addr), spin: newSpinner(), width: 100, height: 30, file: file,
|
||||
st: &storeHolder{file: file}, docs: newDocViewer()}
|
||||
m.node = strings.HasSuffix(file, "agent.yaml")
|
||||
if m.node {
|
||||
m.menu = cSettings
|
||||
}
|
||||
m.loadSettings()
|
||||
return m
|
||||
}
|
||||
@@ -257,7 +263,7 @@ func (m *consoleModel) loadSettings() {
|
||||
|
||||
func (m consoleModel) Init() tea.Cmd {
|
||||
if m.node {
|
||||
return m.spin.Tick
|
||||
return tea.Batch(m.fetchNodeOverview(), m.spin.Tick, tea.Tick(3*time.Second, func(time.Time) tea.Msg { return consoleTickMsg{} }))
|
||||
}
|
||||
return tea.Batch(m.fetch(), m.spin.Tick, tea.Tick(3*time.Second, func(time.Time) tea.Msg { return consoleTickMsg{} }))
|
||||
}
|
||||
@@ -320,7 +326,7 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
return m, cmd
|
||||
case consoleTickMsg:
|
||||
if m.node {
|
||||
return m, nil
|
||||
return m.nodeTick()
|
||||
}
|
||||
cmds := []tea.Cmd{m.fetch(), tea.Tick(3*time.Second, func(time.Time) tea.Msg { return consoleTickMsg{} })}
|
||||
if m.menu == cUsage {
|
||||
@@ -332,9 +338,15 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if m.menu == cGuard {
|
||||
cmds = append(cmds, m.fetchGuard())
|
||||
}
|
||||
if m.menu == cSignIn {
|
||||
cmds = append(cmds, m.fetchSignIn())
|
||||
}
|
||||
if m.menu == cKB {
|
||||
cmds = append(cmds, m.fetchKB())
|
||||
}
|
||||
if m.menu == cMCP {
|
||||
cmds = append(cmds, m.fetchMCP())
|
||||
}
|
||||
if m.menu == cMirror {
|
||||
cmds = append(cmds, m.fetchMirror())
|
||||
}
|
||||
@@ -351,6 +363,8 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
cmds = append(cmds, m.fetchLogs(m.logs.node))
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
case nodeOverviewMsg:
|
||||
m.nodeOv, m.nodeBusy = nodeOverview(msg), false
|
||||
case consoleDataMsg:
|
||||
m.loaded = true
|
||||
if msg.err != nil {
|
||||
@@ -395,6 +409,10 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m.quotas, m.quotasErr = msg.rows, msg.err
|
||||
case consoleKBMsg:
|
||||
m.kb = kbState{view: msg.view, err: msg.err, loaded: true}
|
||||
case consoleMCPMsg:
|
||||
m.mcp = mcpState(msg)
|
||||
case consoleSignInMsg:
|
||||
m.signIn = signInState{view: msg.view, err: msg.err, loaded: true, ldap: msg.ldap, oidc: msg.oidc}
|
||||
case consoleGuardMsg:
|
||||
window := m.guard.window
|
||||
m.guard = guardState{view: msg.view, events: msg.events, err: msg.err, window: window}
|
||||
@@ -423,7 +441,7 @@ func (m consoleModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m consoleModel) entryOK(i int) bool {
|
||||
if m.node {
|
||||
return i == cSettings || i == cManual || i == cWipe || i == cLeave
|
||||
return i == cOverview || i == cSettings || i == cManual || i == cWipe || i == cLeave
|
||||
}
|
||||
return i != cReplicate || !m.storeReplicated()
|
||||
}
|
||||
@@ -553,8 +571,12 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.menuD = m.quotaMenu(m.quotas[m.row])
|
||||
case cGuard:
|
||||
m.menuD = m.guardAttachmentMenu(m.guard.view.Attachments[m.row])
|
||||
case cSignIn:
|
||||
m.menuD = m.signInMenu(m.signInRowKey())
|
||||
case cKB:
|
||||
m.menuD = m.kbMenu(m.kb.view.Collections[m.row])
|
||||
case cMCP:
|
||||
m.menuD = m.mcpMenu(m.mcp.servers[m.row])
|
||||
case cMirror:
|
||||
m.menuD = m.mirrorMenu(m.mirrorGroups()[m.row])
|
||||
case cJobs:
|
||||
@@ -620,6 +642,10 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
if m.kb.view.Available {
|
||||
m.kbCreateForm()
|
||||
}
|
||||
case cMCP:
|
||||
if m.mcp.loaded && m.mcp.err == nil {
|
||||
m.mcpAddForm()
|
||||
}
|
||||
case cModels:
|
||||
m.addModelForm()
|
||||
case cMirror:
|
||||
@@ -649,6 +675,19 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.usageExportForm()
|
||||
return m, nil
|
||||
}
|
||||
if m.menu == cSignIn && m.signIn.loaded {
|
||||
m.entraForm()
|
||||
return m, nil
|
||||
}
|
||||
case "d":
|
||||
if m.menu == cSignIn && m.signIn.loaded {
|
||||
m.directoryForm()
|
||||
return m, nil
|
||||
}
|
||||
case "t":
|
||||
if m.menu == cSignIn && m.signIn.view.LDAP != nil {
|
||||
return m, m.tryDirectory()
|
||||
}
|
||||
case "right", "l":
|
||||
if m.menu == cUsage {
|
||||
m.usageBy = (m.usageBy + 1) % len(usageGroupings)
|
||||
@@ -675,8 +714,12 @@ func (m consoleModel) onEnter() tea.Cmd {
|
||||
return m.fetchQuotas()
|
||||
case cGuard:
|
||||
return m.fetchGuard()
|
||||
case cSignIn:
|
||||
return m.fetchSignIn()
|
||||
case cKB:
|
||||
return m.fetchKB()
|
||||
case cMCP:
|
||||
return m.fetchMCP()
|
||||
case cMirror:
|
||||
return m.fetchMirror()
|
||||
case cMonitor:
|
||||
@@ -1053,8 +1096,12 @@ func (m consoleModel) rowCount() int {
|
||||
return len(m.quotas)
|
||||
case cGuard:
|
||||
return len(m.guard.view.Attachments)
|
||||
case cSignIn:
|
||||
return len(signInRows)
|
||||
case cKB:
|
||||
return len(m.kb.view.Collections)
|
||||
case cMCP:
|
||||
return len(m.mcp.servers)
|
||||
case cMirror:
|
||||
return len(m.mirrorGroups())
|
||||
case cJobs:
|
||||
@@ -1080,9 +1127,6 @@ func (m consoleModel) rowCount() int {
|
||||
func (m consoleModel) View() string {
|
||||
host, _ := os.Hostname()
|
||||
nav := []navItem{{Label: "Watch", Heading: true}}
|
||||
if m.node {
|
||||
nav = nil
|
||||
}
|
||||
for i := 0; i < cCount; i++ {
|
||||
if m.node && !m.entryOK(i) {
|
||||
continue
|
||||
@@ -1177,6 +1221,9 @@ func (m consoleModel) panel() (title, body, keys string) {
|
||||
}
|
||||
switch m.menu {
|
||||
case cOverview:
|
||||
if m.node {
|
||||
return m.nodeOverviewPanel(pw)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
c := &cobra.Command{}
|
||||
c.SetOut(&b)
|
||||
@@ -1292,8 +1339,12 @@ func (m consoleModel) panel() (title, body, keys string) {
|
||||
return m.quotasPanel(pw)
|
||||
case cGuard:
|
||||
return m.guardPanel(pw)
|
||||
case cSignIn:
|
||||
return m.signInPanel(pw)
|
||||
case cKB:
|
||||
return m.kbPanel(pw)
|
||||
case cMCP:
|
||||
return m.mcpPanel(pw)
|
||||
case cAccounts, cKeys, cOrgs:
|
||||
return m.peoplePanel(pw)
|
||||
case cManual:
|
||||
@@ -1648,9 +1699,6 @@ func runConsole(ctx context.Context, out io.Writer, file string) error {
|
||||
}
|
||||
}
|
||||
menu := cOverview
|
||||
if strings.HasSuffix(file, "agent.yaml") {
|
||||
menu = cSettings
|
||||
}
|
||||
for {
|
||||
m := newConsoleModel(addr, file)
|
||||
m.menu = menu
|
||||
|
||||
+14
-7
@@ -107,11 +107,7 @@ func optionLabel(o modelplan.Option) string {
|
||||
if o.FreeNow {
|
||||
now = sOK.Render("free")
|
||||
}
|
||||
need := fmt.Sprintf("%.1f of %.0f GB a card", o.NeedGB, o.HaveGB)
|
||||
if o.CPU {
|
||||
need = fmt.Sprintf("%.1f GB of RAM", o.NeedGB)
|
||||
}
|
||||
return fmt.Sprintf("%s · %s · %s", o.Label(), need, now)
|
||||
return fmt.Sprintf("%s · %s · %s", o.Label(), o.MemoryText(), now)
|
||||
}
|
||||
|
||||
// repoMenu offers a repository's files, each with the best way it runs here;
|
||||
@@ -197,8 +193,15 @@ func (m *consoleModel) serveForm(p modelserve.Plan, a modelserve.PlannedArtifact
|
||||
if p.Note != "" {
|
||||
note += "\n" + sWarn.Render("! ") + p.Note
|
||||
}
|
||||
note += fmt.Sprintf("\nPlanned for a %d-token context. The mirror fetches it and checks it, it joins the\n"+
|
||||
"catalog, and a replica is started and asked to say hello.", p.Context)
|
||||
note += fmt.Sprintf("\nMemory for a %d-token context, each way it runs:", p.Context)
|
||||
for _, o := range opts {
|
||||
note += "\n " + o.Label() + ": " + o.MemoryText()
|
||||
}
|
||||
if p.KVBytesPerToken > 0 {
|
||||
note += fmt.Sprintf("\nThe context costs %.2f GB per 1000 tokens, the whole model; a longer one is checked\n"+
|
||||
"before it is served.", modelplan.GB(p.KVBytesPerToken*1000))
|
||||
}
|
||||
note += "\nThe mirror fetches it and checks it, it joins the catalog, and a replica is started\nand asked to say hello."
|
||||
ctxDefault := "4096"
|
||||
if a.Format == "safetensors" {
|
||||
ctxDefault = strconv.Itoa(p.Context)
|
||||
@@ -234,6 +237,10 @@ func (m *consoleModel) serveForm(p modelserve.Plan, a modelserve.PlannedArtifact
|
||||
req.File = a.Name
|
||||
}
|
||||
o := opts[slices.Index(where, v[4])]
|
||||
// Asked for a context other than planned: does it still fit?
|
||||
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 {
|
||||
req.CPU = true
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// The company's own MCP servers, as the console shows them: each with its
|
||||
// address, the names of the credentials it is sent (never their values),
|
||||
// its rate, and who has been granted its tools; Enter for what can be done
|
||||
// to one, A to add one. Clients reach them all through the gateway's /mcp
|
||||
// with an llmgw key, as server.tool.
|
||||
|
||||
type mcpState struct {
|
||||
servers []adminapi.MCPServerView
|
||||
err error
|
||||
loaded bool
|
||||
// grants are the tool grants in the tenancy tree, nil when the store
|
||||
// could not be read from here; where says what each scope's ID is
|
||||
// called, and ids the other way, for the grant form.
|
||||
grants []mcpGrant
|
||||
where map[string]string
|
||||
ids map[string]store.LimitOwner
|
||||
// keys are the IDs of the keys there are, for a grant to one.
|
||||
keys map[string]bool
|
||||
}
|
||||
|
||||
// mcpGrant is one level of the tree that grants tools, and which.
|
||||
type mcpGrant struct {
|
||||
owner store.LimitOwner
|
||||
label string
|
||||
tools []string
|
||||
}
|
||||
|
||||
type consoleMCPMsg mcpState
|
||||
|
||||
func (m consoleModel) fetchMCP() tea.Cmd {
|
||||
c, h := m.client, m.st
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var msg consoleMCPMsg
|
||||
msg.err = c.get(ctx, "/api/v1/mcp/servers", &msg.servers)
|
||||
msg.loaded = true
|
||||
if h == nil {
|
||||
return msg
|
||||
}
|
||||
s, err := h.get(ctx)
|
||||
if err != nil {
|
||||
return msg
|
||||
}
|
||||
limits, err := s.AllLimits(ctx)
|
||||
if err != nil {
|
||||
return msg
|
||||
}
|
||||
msg.where, msg.ids = map[string]string{}, map[string]store.LimitOwner{}
|
||||
if tree, err := s.Tree(ctx); err == nil {
|
||||
for _, o := range tree {
|
||||
msg.name(store.ScopeOrg, o.ID, o.Name)
|
||||
for _, t := range o.Teams {
|
||||
msg.name(store.ScopeTeam, t.ID, o.Name+"/"+t.Name)
|
||||
for _, p := range t.Projects {
|
||||
msg.name(store.ScopeProject, p.ID, o.Name+"/"+t.Name+"/"+p.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if users, err := s.Users(ctx, ""); err == nil {
|
||||
for _, u := range users {
|
||||
msg.name(store.ScopeUser, u.ID, u.Email)
|
||||
}
|
||||
}
|
||||
msg.keys = map[string]bool{}
|
||||
if keys, err := s.AllKeys(ctx); err == nil {
|
||||
for _, k := range keys {
|
||||
msg.keys[k.ID] = true
|
||||
msg.where[store.LimitOwner{Scope: store.ScopeKey, ID: k.ID}.CounterKey()] = k.Name
|
||||
}
|
||||
}
|
||||
msg.grants = []mcpGrant{}
|
||||
for _, l := range limits {
|
||||
if l.AllowedTools == nil {
|
||||
continue
|
||||
}
|
||||
o := store.LimitOwner{Scope: l.Scope, ID: l.ScopeID}
|
||||
label := msg.where[o.CounterKey()]
|
||||
if label == "" {
|
||||
label = l.ScopeID
|
||||
}
|
||||
msg.grants = append(msg.grants, mcpGrant{owner: o, label: string(l.Scope) + " " + label, tools: l.AllowedTools})
|
||||
}
|
||||
slices.SortFunc(msg.grants, func(a, b mcpGrant) int { return strings.Compare(a.label, b.label) })
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
func (s *consoleMCPMsg) name(scope store.Scope, id, label string) {
|
||||
o := store.LimitOwner{Scope: scope, ID: id}
|
||||
s.where[o.CounterKey()] = label
|
||||
s.ids[string(scope)+":"+strings.ToLower(label)] = o
|
||||
if m := store.MemberScope(scope); m != "" {
|
||||
s.where[store.LimitOwner{Scope: m, ID: id}.CounterKey()] = label
|
||||
}
|
||||
}
|
||||
|
||||
// closed are the levels whose list names no tool at all.
|
||||
func (k mcpState) closed() []mcpGrant {
|
||||
var out []mcpGrant
|
||||
for _, g := range k.grants {
|
||||
if len(g.tools) == 0 {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// mcpGrantsOf is who may use a server's tools, and which of them.
|
||||
func (k mcpState) grantsOf(server string) []mcpGrant {
|
||||
var out []mcpGrant
|
||||
for _, g := range k.grants {
|
||||
var mine []string
|
||||
for _, t := range g.tools {
|
||||
if strings.HasPrefix(t, server+".") || t == "*" {
|
||||
mine = append(mine, t)
|
||||
}
|
||||
}
|
||||
if len(mine) > 0 {
|
||||
out = append(out, mcpGrant{owner: g.owner, label: g.label, tools: mine})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m consoleModel) mcpPanel(pw int) (string, string, string) {
|
||||
keys := "→ or Enter open · ↑↓ choose · Q leave"
|
||||
if m.inPanel {
|
||||
keys = "↑↓ server · Enter actions · A add a server · ← back · Q leave"
|
||||
}
|
||||
k := m.mcp
|
||||
var b strings.Builder
|
||||
b.WriteString("\n " + sDim.Render("The company's MCP servers, offered to agents through the gateway's /mcp as server.tool.") + "\n\n")
|
||||
switch {
|
||||
case k.err != nil:
|
||||
b.WriteString(" " + sBad.Render(k.err.Error()))
|
||||
return "MCP servers", b.String(), keys
|
||||
case !k.loaded:
|
||||
b.WriteString(" " + sDim.Render("asking the control plane…"))
|
||||
return "MCP servers", b.String(), keys
|
||||
case len(k.servers) == 0:
|
||||
b.WriteString(" " + sDim.Render("None yet. A to add one: its address, and the credential it wants."))
|
||||
b.WriteString("\n\n" + mcpClientHint())
|
||||
return "MCP servers", b.String(), keys
|
||||
}
|
||||
var rows [][]string
|
||||
for _, s := range k.servers {
|
||||
state := sOK.Render("on")
|
||||
if !s.Enabled {
|
||||
state = sDim.Render("off")
|
||||
}
|
||||
granted := "—"
|
||||
if k.grants != nil {
|
||||
var who []string
|
||||
for _, g := range k.grantsOf(s.Name) {
|
||||
who = append(who, g.label)
|
||||
}
|
||||
granted = sBad.Render("nobody yet")
|
||||
if len(who) > 0 {
|
||||
granted = strings.Join(who, ", ")
|
||||
}
|
||||
}
|
||||
rate := "—"
|
||||
if s.RatePerMinute > 0 {
|
||||
rate = fmt.Sprintf("%d/min", s.RatePerMinute)
|
||||
}
|
||||
rows = append(rows, []string{s.Name, state, granted, rate, dash(strings.Join(mcpCredentials(s), ", ")), s.URL})
|
||||
}
|
||||
b.WriteString(table([]string{"SERVER", "STATE", "GRANTED TO", "RATE", "SENT", "ADDRESS"}, rows, m.row, pw, m.inPanel))
|
||||
b.WriteString("\n\n " + sDim.Render("A server's tools are seen only where they are granted; nothing is granted by default.") + "\n")
|
||||
if closed := k.closed(); len(closed) > 0 {
|
||||
var names []string
|
||||
for _, g := range closed {
|
||||
names = append(names, g.label)
|
||||
}
|
||||
b.WriteString(" " + sBad.Render("Closed to every tool: ") + strings.Join(names, ", ") +
|
||||
sDim.Render(" (a server's menu opens them)") + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(mcpClientHint())
|
||||
return "MCP servers", b.String(), keys
|
||||
}
|
||||
|
||||
// mcpClientHint is how a person's agent is pointed at the gateway.
|
||||
func mcpClientHint() string {
|
||||
return " " + sDim.Render("In a person's agent: the gateway's https://…/mcp, with their key from /profile/keys") + "\n" +
|
||||
" " + sDim.Render(" claude mcp add --transport http company https://HOST/mcp --header \"Authorization: Bearer sk-llmgw-…\"")
|
||||
}
|
||||
|
||||
// mcpCredentials are the names of the headers a server is sent: which are
|
||||
// kept as secrets and which as written. Never their values.
|
||||
func mcpCredentials(s adminapi.MCPServerView) []string {
|
||||
var out []string
|
||||
for h := range s.HeaderRefs {
|
||||
out = append(out, h+" (secret)")
|
||||
}
|
||||
for h := range s.Headers {
|
||||
out = append(out, h)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// mcpPath is a server's admin API address.
|
||||
func mcpPath(name string) string { return "/api/v1/mcp/servers/" + url.PathEscape(name) }
|
||||
|
||||
// mcpAuth turns the form's credential fields into what the server is sent:
|
||||
// a bearer token, another header, or nothing.
|
||||
func mcpAuth(kind, header, secret string) (map[string]string, error) {
|
||||
switch kind {
|
||||
case "none":
|
||||
return nil, nil
|
||||
case "bearer":
|
||||
if secret == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return map[string]string{"Authorization": "Bearer " + strings.TrimPrefix(secret, "Bearer ")}, nil
|
||||
default:
|
||||
if header == "" {
|
||||
return nil, fmt.Errorf("which header carries the credential?")
|
||||
}
|
||||
if secret == "" {
|
||||
return nil, nil
|
||||
}
|
||||
return map[string]string{header: secret}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func atoiOr(s string, def int) (int, error) {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return def, nil
|
||||
}
|
||||
return strconv.Atoi(strings.TrimSpace(s))
|
||||
}
|
||||
|
||||
// mcpAddForm registers a server.
|
||||
func (m *consoleModel) mcpAddForm() {
|
||||
m.openForm(&formDialog{title: "Add an MCP server",
|
||||
note: "Reached by the control plane over HTTP (streamable HTTP or SSE). Its credential is kept in\n" +
|
||||
"the store, sealed, and never reaches a client. Its tools are seen nowhere until granted.",
|
||||
fields: []formField{
|
||||
textF("name", "", "lowercase, the tools' prefix: jira → jira.search"),
|
||||
textF("address", "", "https://jira-mcp.internal/mcp"),
|
||||
textF("about", "", "what it is for, in a line"),
|
||||
choiceF("credential", []string{"bearer", "header", "none"}, "bearer"),
|
||||
textF("header", "", "for credential: header, its name (X-Api-Key)"),
|
||||
pwF("secret", "the token or key; kept sealed in the store"),
|
||||
textF("timeout", "", "seconds a call may take; empty is the default"),
|
||||
textF("rate", "", "calls a minute per caller; empty is no bound"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
name, addr := strings.TrimSpace(v[0]), strings.TrimSpace(v[1])
|
||||
if name == "" || addr == "" {
|
||||
return nil, fmt.Errorf("a name and an address")
|
||||
}
|
||||
if err := adminapi.CheckMCPAddress(addr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
secrets, err := mcpAuth(v[3], strings.TrimSpace(v[4]), v[5])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout, err := atoiOr(v[6], 0)
|
||||
if err != nil || timeout < 0 {
|
||||
return nil, fmt.Errorf("timeout is whole seconds")
|
||||
}
|
||||
rate, err := atoiOr(v[7], 0)
|
||||
if err != nil || rate < 0 {
|
||||
return nil, fmt.Errorf("rate is calls a minute")
|
||||
}
|
||||
on := true
|
||||
req := adminapi.MCPServerRequest{URL: addr, Description: v[2], Secrets: secrets, Enabled: &on,
|
||||
TimeoutSeconds: timeout, RatePerMinute: rate}
|
||||
return m.call("Add "+name, http.MethodPut, mcpPath(name), req, func(json.RawMessage) string {
|
||||
return "Added " + name + ". Test it from its menu, then grant its tools: nobody sees them yet."
|
||||
}), nil
|
||||
}})
|
||||
}
|
||||
|
||||
// mcpMenu is what can be done to one server.
|
||||
func (m consoleModel) mcpMenu(s adminapi.MCPServerView) *menuDialog {
|
||||
items := []menuItem{
|
||||
{"Test, and list its tools", func(m *consoleModel) tea.Cmd { return m.mcpTest(s) }},
|
||||
{"Grant its tools to…", func(m *consoleModel) tea.Cmd { m.mcpGrantForm(s); return nil }},
|
||||
}
|
||||
for _, g := range m.mcp.grantsOf(s.Name) {
|
||||
if !slices.ContainsFunc(g.tools, func(t string) bool { return t != "*" }) {
|
||||
continue // every tool, granted with the Limits form; revoked there too
|
||||
}
|
||||
items = append(items, menuItem{"Revoke from " + g.label + " (" + strings.Join(g.tools, ", ") + ")…",
|
||||
func(m *consoleModel) tea.Cmd {
|
||||
m.confirmD = &confirmDialog{title: "Revoke " + s.Name + " from " + g.label,
|
||||
text: "Taken from it and from everything beneath it. Its other grants stay; left naming\n" +
|
||||
"no tool, it is closed rather than opened to what the levels above allow.",
|
||||
yes: func(m *consoleModel) tea.Cmd { return m.mcpSetTools(s.Name, g.owner, nil) }}
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
for _, g := range m.mcp.closed() {
|
||||
items = append(items, menuItem{"Open " + g.label + ", closed to every tool…", func(m *consoleModel) tea.Cmd {
|
||||
m.confirmD = &confirmDialog{title: "Open " + g.label,
|
||||
text: "It then names no tools of its own and sees what the levels above it allow.",
|
||||
yes: func(m *consoleModel) tea.Cmd { return m.mcpOpen(g.owner) }}
|
||||
return nil
|
||||
}})
|
||||
}
|
||||
items = append(items,
|
||||
menuItem{"Change…", func(m *consoleModel) tea.Cmd { m.mcpEditForm(s); return nil }},
|
||||
menuItem{"Replace its credential…", func(m *consoleModel) tea.Cmd { m.mcpCredentialForm(s); return nil }},
|
||||
)
|
||||
if s.Enabled {
|
||||
items = append(items, menuItem{"Turn off", func(m *consoleModel) tea.Cmd {
|
||||
off := false
|
||||
return m.call("Turn off "+s.Name, http.MethodPut, mcpPath(s.Name), adminapi.MCPServerRequest{URL: s.URL, Enabled: &off},
|
||||
func(json.RawMessage) string {
|
||||
return s.Name + " is off: its tools are listed nowhere and every call is refused."
|
||||
})
|
||||
}})
|
||||
} else {
|
||||
items = append(items, menuItem{"Turn on", func(m *consoleModel) tea.Cmd {
|
||||
on := true
|
||||
return m.call("Turn on "+s.Name, http.MethodPut, mcpPath(s.Name), adminapi.MCPServerRequest{URL: s.URL, Enabled: &on}, nil)
|
||||
}})
|
||||
}
|
||||
items = append(items, menuItem{"Remove…", func(m *consoleModel) tea.Cmd {
|
||||
m.confirmD = &confirmDialog{title: "Remove " + s.Name,
|
||||
text: "Its tools go from every agent at once, and its sealed credential with it.\nThe grants stay in the tree until revoked.",
|
||||
yes: func(m *consoleModel) tea.Cmd {
|
||||
return m.call("Remove "+s.Name, http.MethodDelete, mcpPath(s.Name), nil, nil)
|
||||
}}
|
||||
return nil
|
||||
}})
|
||||
return &menuDialog{title: s.Name + " · " + s.URL, items: items}
|
||||
}
|
||||
|
||||
// mcpTest asks the server for its tools, with its credentials, as a call
|
||||
// through the gateway would.
|
||||
func (m *consoleModel) mcpTest(s adminapi.MCPServerView) tea.Cmd {
|
||||
return m.call("Test "+s.Name, http.MethodPost, mcpPath(s.Name)+"/test", nil, func(raw json.RawMessage) string {
|
||||
var t adminapi.MCPTestView
|
||||
_ = json.Unmarshal(raw, &t)
|
||||
if !t.OK {
|
||||
return "It did not answer: " + t.Error
|
||||
}
|
||||
if len(t.Tools) == 0 {
|
||||
return "It answers, and offers no tools."
|
||||
}
|
||||
return fmt.Sprintf("It answers, with %d tools:\n\n %s", len(t.Tools), strings.Join(t.Tools, "\n "))
|
||||
})
|
||||
}
|
||||
|
||||
func (m *consoleModel) mcpEditForm(s adminapi.MCPServerView) {
|
||||
rate := ""
|
||||
if s.RatePerMinute > 0 {
|
||||
rate = strconv.Itoa(s.RatePerMinute)
|
||||
}
|
||||
timeout := ""
|
||||
if s.TimeoutSeconds > 0 {
|
||||
timeout = strconv.Itoa(s.TimeoutSeconds)
|
||||
}
|
||||
m.openForm(&formDialog{title: "Change " + s.Name,
|
||||
note: "Its credential stays as it is; Replace its credential for that.",
|
||||
fields: []formField{
|
||||
textF("address", s.URL, ""),
|
||||
textF("about", s.Description, ""),
|
||||
textF("timeout", timeout, "seconds a call may take"),
|
||||
textF("rate", rate, "calls a minute per caller; 0 is no bound"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
if err := adminapi.CheckMCPAddress(strings.TrimSpace(v[0])); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
timeout, err := atoiOr(v[2], 0)
|
||||
if err != nil || timeout < 0 {
|
||||
return nil, fmt.Errorf("timeout is whole seconds")
|
||||
}
|
||||
rate, err := atoiOr(v[3], 0)
|
||||
if err != nil || rate < 0 {
|
||||
return nil, fmt.Errorf("rate is calls a minute")
|
||||
}
|
||||
req := adminapi.MCPServerRequest{URL: strings.TrimSpace(v[0]), Description: v[1], TimeoutSeconds: timeout,
|
||||
RatePerMinute: rate}
|
||||
// An emptied field empties it, rather than leaving it as it was.
|
||||
if strings.TrimSpace(v[1]) == "" {
|
||||
req.Clear = append(req.Clear, "description")
|
||||
}
|
||||
if timeout == 0 {
|
||||
req.Clear = append(req.Clear, "timeout")
|
||||
}
|
||||
if rate == 0 {
|
||||
req.Clear = append(req.Clear, "rate")
|
||||
}
|
||||
return m.call("Change "+s.Name, http.MethodPut, mcpPath(s.Name), req, nil), nil
|
||||
}})
|
||||
}
|
||||
|
||||
func (m *consoleModel) mcpCredentialForm(s adminapi.MCPServerView) {
|
||||
m.openForm(&formDialog{title: "Replace the credential " + s.Name + " is sent",
|
||||
note: "Kept in the store, sealed. The old value is overwritten, not shown.",
|
||||
fields: []formField{
|
||||
choiceF("credential", []string{"bearer", "header"}, "bearer"),
|
||||
textF("header", "", "for credential: header, its name (X-Api-Key)"),
|
||||
pwF("secret", "the token or key"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
if v[2] == "" {
|
||||
return nil, fmt.Errorf("the new secret?")
|
||||
}
|
||||
secrets, err := mcpAuth(v[0], strings.TrimSpace(v[1]), v[2])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return m.call("Credential of "+s.Name, http.MethodPut, mcpPath(s.Name),
|
||||
adminapi.MCPServerRequest{URL: s.URL, Secrets: secrets, HeaderRefs: map[string]string{}}, nil), nil
|
||||
}})
|
||||
}
|
||||
|
||||
// mcpGrantForm grants a server's tools to part of the tree, a person or a
|
||||
// key, beside whatever else that level is granted.
|
||||
func (m *consoleModel) mcpGrantForm(s adminapi.MCPServerView) {
|
||||
if m.mcp.ids == nil {
|
||||
m.dialog, m.dialogTitle = "error", "Grant "+s.Name
|
||||
m.dialogText = "The store cannot be read from this machine, so its grants cannot be changed here.\n" +
|
||||
"On the control plane: llmgw keys limits --scope team --id ORG/TEAM --tools '" + s.Name + ".*'"
|
||||
return
|
||||
}
|
||||
m.openForm(&formDialog{title: "Grant " + s.Name + "'s tools",
|
||||
note: "Seen there, and below, where every level above that names tools names these too.\n" +
|
||||
"A level given its first grant keeps what it saw from above; other grants stay.",
|
||||
fields: []formField{
|
||||
choiceF("to", []string{"team", "project", "org", "user", "key"}, "team"),
|
||||
textF("which", "", "ORG/TEAM/PROJECT, ORG/TEAM, ORG, an e-mail, or a key's ID"),
|
||||
textF("tools", s.Name+".*", "patterns: "+s.Name+".* for all, or "+s.Name+".search,"+s.Name+".get"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
which := strings.TrimSpace(v[1])
|
||||
if which == "" {
|
||||
return nil, fmt.Errorf("which %s?", v[0])
|
||||
}
|
||||
owner, ok := m.mcp.ids[v[0]+":"+strings.ToLower(which)]
|
||||
if v[0] == "key" {
|
||||
owner, ok = store.LimitOwner{Scope: store.ScopeKey, ID: which}, m.mcp.keys[which]
|
||||
}
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no %s %q", v[0], which)
|
||||
}
|
||||
var tools []string
|
||||
for _, t := range strings.Split(v[2], ",") {
|
||||
if t = strings.TrimSpace(t); t != "" {
|
||||
if !strings.HasPrefix(t, s.Name+".") {
|
||||
return nil, fmt.Errorf("%q is not one of %s's tools: they start %s.", t, s.Name, s.Name)
|
||||
}
|
||||
tools = append(tools, t)
|
||||
}
|
||||
}
|
||||
if len(tools) == 0 {
|
||||
return nil, fmt.Errorf("which tools? %s.* for all", s.Name)
|
||||
}
|
||||
return m.mcpSetTools(s.Name, owner, tools), nil
|
||||
}})
|
||||
}
|
||||
|
||||
// mcpSetTools grants a level these of a server's tools, or with none takes
|
||||
// them away, in the store (store.GrantTools, store.RevokeTools): read and
|
||||
// written at once, so another administrator's change since this screen
|
||||
// was loaded is kept.
|
||||
func (m *consoleModel) mcpSetTools(server string, owner store.LimitOwner, tools []string) tea.Cmd {
|
||||
h, where := m.st, m.mcp.where
|
||||
title := "Grant " + server
|
||||
if len(tools) == 0 {
|
||||
title = "Revoke " + server
|
||||
}
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
s, err := h.get(ctx)
|
||||
if err != nil {
|
||||
return actionDoneMsg{title: title, err: err}
|
||||
}
|
||||
if len(tools) == 0 {
|
||||
if err := s.RevokeTools(ctx, owner.Scope, owner.ID, server); err != nil {
|
||||
return actionDoneMsg{title: title, err: err}
|
||||
}
|
||||
return actionDoneMsg{title: title, text: server + "'s tools taken back there; its other grants stay."}
|
||||
}
|
||||
g, err := s.GrantTools(ctx, owner.Scope, owner.ID, server, tools)
|
||||
if err != nil {
|
||||
return actionDoneMsg{title: title, err: err}
|
||||
}
|
||||
text := "Granted " + strings.Join(tools, ", ") + "."
|
||||
for _, b := range g.BlockedBy {
|
||||
text += "\n\nBut " + string(b.Scope) + " " + or(where[b.CounterKey()], b.ID) + " names tools and not these:\n" +
|
||||
server + "'s stay out of reach there until it grants them too."
|
||||
}
|
||||
return actionDoneMsg{title: title, text: text}
|
||||
}
|
||||
}
|
||||
|
||||
// mcpOpen has a closed level name no tools of its own (store.OpenTools).
|
||||
func (m *consoleModel) mcpOpen(owner store.LimitOwner) tea.Cmd {
|
||||
h := m.st
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
s, err := h.get(ctx)
|
||||
if err == nil {
|
||||
err = s.OpenTools(ctx, owner.Scope, owner.ID)
|
||||
}
|
||||
if err != nil {
|
||||
return actionDoneMsg{title: "Open", err: err}
|
||||
}
|
||||
return actionDoneMsg{title: "Open", text: "Opened: it names no tools of its own now."}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func TestAnMCPServerIsAddedAndTestedFromTheConsole(t *testing.T) {
|
||||
m, s := consoleWith(t, cMCP)
|
||||
m.mcp = mcpState{loaded: true, grants: []mcpGrant{}, servers: []adminapi.MCPServerView{{
|
||||
Name: "jira", URL: "https://jira-mcp.internal/mcp", Enabled: true, RatePerMinute: 30,
|
||||
HeaderRefs: map[string]string{"Authorization": "store:mcp/jira/Authorization"},
|
||||
}}}
|
||||
var tm tea.Model = m
|
||||
view := tm.View()
|
||||
for _, want := range []string{"MCP servers", "jira", "Authorization (secret)",
|
||||
"30/min", "nobody yet", "claude mcp add"} {
|
||||
if !strings.Contains(view, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, view)
|
||||
}
|
||||
}
|
||||
if strings.Contains(view, "store:mcp/jira") {
|
||||
t.Error("where the secret is kept is shown")
|
||||
}
|
||||
|
||||
// A: a server with a bearer token, which is sent once and kept sealed.
|
||||
tm = press(tm, "a")
|
||||
f := tm.(consoleModel).formD
|
||||
if f == nil {
|
||||
t.Fatal("no form")
|
||||
}
|
||||
f.fields[0].text.SetValue("hr")
|
||||
f.fields[1].text.SetValue("https://hr-mcp.internal/mcp")
|
||||
f.fields[5].text.SetValue("t0ken")
|
||||
f.focus = len(f.fields)
|
||||
_ = pressRun(tm, "enter")
|
||||
got := s.last()
|
||||
var req adminapi.MCPServerRequest
|
||||
if !strings.HasPrefix(got, "PUT /api/v1/mcp/servers/hr ") ||
|
||||
json.Unmarshal([]byte(strings.SplitN(got, " ", 3)[2]), &req) != nil ||
|
||||
req.URL != "https://hr-mcp.internal/mcp" || req.Secrets["Authorization"] != "Bearer t0ken" ||
|
||||
req.Enabled == nil || !*req.Enabled {
|
||||
t.Fatalf("sent %q", got)
|
||||
}
|
||||
|
||||
// Enter on jira: test it, then turn it off.
|
||||
tm = press(m, "enter")
|
||||
md := tm.(consoleModel).menuD
|
||||
if md == nil || md.items[0].label != "Test, and list its tools" || !strings.Contains(md.title, "https://jira-mcp.internal/mcp") {
|
||||
t.Fatalf("menu %+v", md)
|
||||
}
|
||||
_ = pressRun(tm, "enter")
|
||||
if got := s.last(); got != "POST /api/v1/mcp/servers/jira/test " {
|
||||
t.Fatalf("sent %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnMCPServersToolsAreGrantedBesideOtherGrants(t *testing.T) {
|
||||
// The servers, from a control plane; the grants, from the store.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode([]adminapi.MCPServerView{
|
||||
{Name: "hr", URL: "https://hr/mcp", Enabled: true}, {Name: "jira", URL: "https://jira/mcp", Enabled: true}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
dir := t.TempDir()
|
||||
cfgPath := dir + "/server.yaml"
|
||||
if err := os.WriteFile(cfgPath, []byte("auth:\n store: "+dir+"/llmgw.db\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := config.Load(cfgPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := newConsoleModel(srv.URL, cfgPath)
|
||||
defer m.st.close()
|
||||
m.loaded, m.inPanel, m.menu = true, true, cMCP
|
||||
ctx := context.Background()
|
||||
st, err := m.st.get(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
org, _ := st.CreateOrg(ctx, "Acme")
|
||||
team, _ := st.CreateTeam(ctx, org, "ml")
|
||||
if err := st.PutLimits(ctx, store.Limits{Scope: store.ScopeTeam, ScopeID: team, AllowedTools: []string{"jira.*"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
toolsOf := func() []string {
|
||||
all, _ := st.AllLimits(ctx)
|
||||
return all[store.LimitOwner{Scope: store.ScopeTeam, ID: team}.CounterKey()].AllowedTools
|
||||
}
|
||||
refresh := func(tm tea.Model) tea.Model {
|
||||
tm, _ = tm.Update(tm.(consoleModel).fetchMCP()())
|
||||
return tm
|
||||
}
|
||||
|
||||
var tm tea.Model = refresh(m)
|
||||
if v := tm.View(); !strings.Contains(v, "team Acme/ml") || !strings.Contains(v, "nobody yet") {
|
||||
t.Fatalf("jira's grant, and hr's none, should show:\n%s", v)
|
||||
}
|
||||
// hr is the first row: grant its tools to the team.
|
||||
tm = press(tm, "enter", "down", "enter")
|
||||
f := tm.(consoleModel).formD
|
||||
if f == nil || f.fields[2].value() != "hr.*" {
|
||||
t.Fatalf("no grant form, or not for hr: %+v", f)
|
||||
}
|
||||
f.fields[1].text.SetValue("Acme/ml")
|
||||
f.focus = len(f.fields)
|
||||
tm = pressRun(tm, "enter")
|
||||
if got := toolsOf(); !slices.Equal(got, []string{"jira.*", "hr.*"}) {
|
||||
t.Fatalf("the team's grants are %v; jira's should have stayed beside hr's", got)
|
||||
}
|
||||
|
||||
// Revoke hr from the team: jira's stays.
|
||||
tm = refresh(press(tm, "x"))
|
||||
tm = press(tm, "enter", "down", "down", "enter")
|
||||
if c := tm.(consoleModel).confirmD; c == nil || !strings.Contains(c.title, "Revoke hr from team Acme/ml") {
|
||||
t.Fatalf("no revoke: %+v", c)
|
||||
}
|
||||
_ = pressRun(tm, "y")
|
||||
if got := toolsOf(); !slices.Equal(got, []string{"jira.*"}) {
|
||||
t.Fatalf("after the revoke the team's grants are %v", got)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ func (m consoleModel) mirrorClient() (*mirror.Client, error) {
|
||||
if cfg.Mirror.API == "" {
|
||||
return nil, errors.New("no mirror: mirror.api in server.yaml is empty")
|
||||
}
|
||||
return mirror.NewClient(cfg.Mirror.API, cfg.Mirror.TLS, 10*time.Second)
|
||||
return mirror.NewFailoverClient(append([]string{cfg.Mirror.API}, cfg.Mirror.Peers...), cfg.Mirror.TLS, 10*time.Second)
|
||||
}
|
||||
|
||||
func (m consoleModel) fetchMirror() tea.Cmd {
|
||||
|
||||
@@ -0,0 +1,715 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/engines"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/version"
|
||||
)
|
||||
|
||||
// A node's overview: what a bare `sudo llmgw` opens on a machine that runs
|
||||
// only llmgw-agent. Everything on it is read here — the agent's own files,
|
||||
// systemd, the cards and the disks — because a node has no admin token, and
|
||||
// the control plane may be the very thing it cannot reach.
|
||||
|
||||
// nodeCmdTimeout bounds every command and check the overview runs: a screen
|
||||
// refreshed every three seconds cannot wait on a tool that hangs.
|
||||
const nodeCmdTimeout = 2 * time.Second
|
||||
|
||||
// nodeControlPlanesFile is where the agent keeps the control planes the
|
||||
// leader last named (controlPlanesFile in the agent package).
|
||||
var nodeControlPlanesFile = "/var/lib/llmgw-agent/control-planes.json"
|
||||
|
||||
// nodeProblemLines is how many of the agent's warnings the overview shows.
|
||||
const nodeProblemLines = 6
|
||||
|
||||
type nodeOverviewMsg nodeOverview
|
||||
|
||||
// nodeOverview is everything the overview shows, read in one go.
|
||||
type nodeOverview struct {
|
||||
at time.Time
|
||||
version string
|
||||
agent agentService
|
||||
planes []planeCheck
|
||||
gpus []nodeGPU
|
||||
// gpuNote says why there are no GPU rows: no card, or no answer.
|
||||
gpuNote string
|
||||
// names is the cards' names by index, which do not change and are asked
|
||||
// for once rather than every refresh.
|
||||
names map[int]string
|
||||
engines []engineRun
|
||||
enginesNote string
|
||||
disks []diskUse
|
||||
problems []string
|
||||
// problemsNote replaces the problems when the journal cannot be read.
|
||||
problemsNote string
|
||||
}
|
||||
|
||||
// agentService is what systemd says of llmgw-agent.
|
||||
type agentService struct {
|
||||
state, sub string
|
||||
since time.Time
|
||||
// note replaces the rest when systemd cannot say.
|
||||
note string
|
||||
}
|
||||
|
||||
// planeCheck is one control plane the agent dials, and whether it answers.
|
||||
type planeCheck struct {
|
||||
addr string
|
||||
// learned is an address the leader named rather than one in agent.yaml.
|
||||
learned bool
|
||||
err string
|
||||
}
|
||||
|
||||
type nodeGPU struct {
|
||||
index int
|
||||
name string
|
||||
// util is -1 where the driver does not report it.
|
||||
util float64
|
||||
memUsedMB, memTotalMB float64
|
||||
tempC float64
|
||||
models []string
|
||||
}
|
||||
|
||||
// engineRun is one engine the supervisor started here, from its state file.
|
||||
type engineRun struct {
|
||||
replica, model, engine string
|
||||
gpus []int
|
||||
port, pid int
|
||||
alive bool
|
||||
started time.Time
|
||||
}
|
||||
|
||||
type diskUse struct {
|
||||
what, path string
|
||||
free, total uint64
|
||||
err string
|
||||
}
|
||||
|
||||
// nodeProbe reads this machine. Every path and tool is a field, so a test
|
||||
// reads a directory of its own instead.
|
||||
type nodeProbe struct {
|
||||
configured []string
|
||||
planesFile string
|
||||
workDir string
|
||||
weightsDir string
|
||||
enginesDir string
|
||||
root bool
|
||||
names map[int]string
|
||||
|
||||
run func(ctx context.Context, name string, args ...string) ([]byte, error)
|
||||
dial func(ctx context.Context, addr string) error
|
||||
gpus func(ctx context.Context) []discovery.GPUSensor
|
||||
cards func(ctx context.Context) ([]discovery.GPU, error)
|
||||
alive func(pid int) bool
|
||||
statfs func(path string) (free, total uint64, err error)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// newNodeProbe reads the machine agent.yaml describes, as the agent would.
|
||||
func newNodeProbe(cfg any, names map[int]string) nodeProbe {
|
||||
c, ok := cfg.(agent.Config)
|
||||
if !ok {
|
||||
c = agent.DefaultConfig()
|
||||
}
|
||||
var configured []string
|
||||
if c.Server != "" {
|
||||
configured = append(configured, c.Server)
|
||||
}
|
||||
configured = append(configured, c.Servers...)
|
||||
// The same defaults the agent applies as it starts.
|
||||
weights := c.WeightsCache
|
||||
if weights == "" && len(c.ModelDirs) > 0 {
|
||||
weights = c.ModelDirs[0]
|
||||
}
|
||||
if weights == "" {
|
||||
weights = agent.DefaultWeightsCache
|
||||
}
|
||||
enginesDir := c.EnginesDir
|
||||
if enginesDir == "" {
|
||||
enginesDir = provision.DefaultRoot
|
||||
}
|
||||
work := c.Superv.WorkDir
|
||||
if work == "" {
|
||||
work = "/var/lib/llmgw-agent"
|
||||
}
|
||||
return nodeProbe{
|
||||
configured: configured, planesFile: nodeControlPlanesFile, workDir: work,
|
||||
weightsDir: weights, enginesDir: enginesDir, root: os.Geteuid() == 0, names: names,
|
||||
run: runLocal, dial: dialTCP, gpus: discovery.SampleGPUs,
|
||||
cards: func(ctx context.Context) ([]discovery.GPU, error) { return discovery.DiscoverGPUsSMI(ctx, "") },
|
||||
alive: pidAlive, statfs: statfs, now: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
// fetchNodeOverview reads the machine in the background.
|
||||
func (m consoleModel) fetchNodeOverview() tea.Cmd {
|
||||
p := newNodeProbe(m.cfg, m.nodeOv.names)
|
||||
return func() tea.Msg { return nodeOverviewMsg(p.gather(context.Background())) }
|
||||
}
|
||||
|
||||
// nodeTick refreshes the overview while it is on screen, one reading at a
|
||||
// time; the other screens of a node watch nothing.
|
||||
func (m consoleModel) nodeTick() (consoleModel, tea.Cmd) {
|
||||
tick := tea.Tick(3*time.Second, func(time.Time) tea.Msg { return consoleTickMsg{} })
|
||||
if m.menu != cOverview || m.nodeBusy {
|
||||
return m, tick
|
||||
}
|
||||
m.nodeBusy = true
|
||||
return m, tea.Batch(m.fetchNodeOverview(), tick)
|
||||
}
|
||||
|
||||
// gather reads everything at once, each part in its own goroutine, so the
|
||||
// slowest part bounds the whole.
|
||||
func (p nodeProbe) gather(ctx context.Context) nodeOverview {
|
||||
ov := nodeOverview{version: version.Version}
|
||||
var wg sync.WaitGroup
|
||||
do := func(f func()) {
|
||||
wg.Add(1)
|
||||
go func() { defer wg.Done(); f() }()
|
||||
}
|
||||
var engines []engineRun
|
||||
do(func() { ov.agent = p.agentState(ctx) })
|
||||
do(func() { ov.planes = p.checkPlanes(ctx) })
|
||||
do(func() { ov.gpus, ov.names, ov.gpuNote = p.readGPUs(ctx) })
|
||||
do(func() { engines, ov.enginesNote = readShardStates(p.workDir, p.alive) })
|
||||
do(func() { ov.disks = p.readDisks() })
|
||||
do(func() { ov.problems, ov.problemsNote = p.readProblems(ctx) })
|
||||
wg.Wait()
|
||||
ov.engines = engines
|
||||
ov.gpus = modelsOnGPUs(ov.gpus, engines)
|
||||
ov.at = p.now()
|
||||
return ov
|
||||
}
|
||||
|
||||
// --- the agent ---
|
||||
|
||||
func (p nodeProbe) agentState(ctx context.Context) agentService {
|
||||
out, err := p.run(ctx, "systemctl", "show", "llmgw-agent",
|
||||
"-p", "LoadState", "-p", "ActiveState", "-p", "SubState", "-p", "ActiveEnterTimestamp")
|
||||
if err != nil && len(out) == 0 {
|
||||
return agentService{note: "systemctl is not available: " + err.Error()}
|
||||
}
|
||||
return parseSystemctlShow(string(out))
|
||||
}
|
||||
|
||||
// parseSystemctlShow reads `systemctl show -p …`: one key=value a line.
|
||||
func parseSystemctlShow(out string) agentService {
|
||||
kv := map[string]string{}
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if k, v, ok := strings.Cut(strings.TrimSpace(line), "="); ok {
|
||||
kv[k] = v
|
||||
}
|
||||
}
|
||||
if kv["LoadState"] == "not-found" {
|
||||
return agentService{note: "llmgw-agent is not installed as a service here"}
|
||||
}
|
||||
s := agentService{state: kv["ActiveState"], sub: kv["SubState"]}
|
||||
if s.state == "" {
|
||||
s.note = "systemd does not say"
|
||||
}
|
||||
s.since = parseSystemdTime(kv["ActiveEnterTimestamp"])
|
||||
return s
|
||||
}
|
||||
|
||||
// parseSystemdTime reads systemd's "Mon 2026-10-19 08:12:03 CEST", in this
|
||||
// machine's zone; zero when it is empty or n/a.
|
||||
func parseSystemdTime(v string) time.Time {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" || v == "n/a" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{"Mon 2006-01-02 15:04:05 MST", "Mon 2006-01-02 15:04:05 -0700", "Mon 2006-01-02 15:04:05"} {
|
||||
if t, err := time.ParseInLocation(layout, v, time.Local); err == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
// --- the control planes ---
|
||||
|
||||
// readControlPlanes is every address the agent dials, in its order: the
|
||||
// configured ones, then those the leader named that agent.yaml lacks.
|
||||
func readControlPlanes(file string, configured []string) []planeCheck {
|
||||
var out []planeCheck
|
||||
seen := map[string]bool{}
|
||||
add := func(s string, learned bool) {
|
||||
hp := hostPort(s)
|
||||
if hp == "" || seen[hp] {
|
||||
return
|
||||
}
|
||||
seen[hp] = true
|
||||
out = append(out, planeCheck{addr: hp, learned: learned})
|
||||
}
|
||||
for _, s := range configured {
|
||||
add(s, false)
|
||||
}
|
||||
if b, err := os.ReadFile(file); err == nil {
|
||||
var told []string
|
||||
if json.Unmarshal(b, &told) == nil {
|
||||
for _, s := range told {
|
||||
add(s, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// hostPort is the host:port a control plane's address dials, whether it was
|
||||
// written as a URL or not.
|
||||
func hostPort(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
port := ""
|
||||
switch {
|
||||
case strings.HasPrefix(s, "https://"):
|
||||
s, port = strings.TrimPrefix(s, "https://"), "443"
|
||||
case strings.HasPrefix(s, "http://"):
|
||||
s, port = strings.TrimPrefix(s, "http://"), "80"
|
||||
}
|
||||
s, _, _ = strings.Cut(s, "/")
|
||||
if _, _, err := net.SplitHostPort(s); err != nil && port != "" && s != "" {
|
||||
s = net.JoinHostPort(strings.Trim(s, "[]"), port)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (p nodeProbe) checkPlanes(ctx context.Context) []planeCheck {
|
||||
planes := readControlPlanes(p.planesFile, p.configured)
|
||||
var wg sync.WaitGroup
|
||||
for i := range planes {
|
||||
wg.Add(1)
|
||||
go func(c *planeCheck) {
|
||||
defer wg.Done()
|
||||
if err := p.dial(ctx, c.addr); err != nil {
|
||||
c.err = err.Error()
|
||||
}
|
||||
}(&planes[i])
|
||||
}
|
||||
wg.Wait()
|
||||
return planes
|
||||
}
|
||||
|
||||
func dialTCP(ctx context.Context, addr string) error {
|
||||
d := net.Dialer{Timeout: nodeCmdTimeout}
|
||||
c, err := d.DialContext(ctx, "tcp", addr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Close()
|
||||
}
|
||||
|
||||
// --- the GPUs ---
|
||||
|
||||
// readGPUs samples the cards, naming them from discovery the first time.
|
||||
func (p nodeProbe) readGPUs(ctx context.Context) ([]nodeGPU, map[int]string, string) {
|
||||
ctx, cancel := context.WithTimeout(ctx, nodeCmdTimeout)
|
||||
defer cancel()
|
||||
sensors := p.gpus(ctx)
|
||||
if len(sensors) == 0 {
|
||||
// Without nvidia-smi discovery says nothing and no error: a machine
|
||||
// without a card. With it and no answer, a driver in trouble.
|
||||
if _, err := p.cards(ctx); err != nil {
|
||||
return nil, p.names, "The GPUs do not answer: " + err.Error()
|
||||
}
|
||||
return nil, p.names, "No GPU on this machine."
|
||||
}
|
||||
names := p.names
|
||||
for _, s := range sensors {
|
||||
if names[s.Index] == "" {
|
||||
names = map[int]string{}
|
||||
if cards, err := p.cards(ctx); err == nil {
|
||||
for _, c := range cards {
|
||||
if c.MIG == nil {
|
||||
names[c.Index] = c.Name
|
||||
}
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
var out []nodeGPU
|
||||
for _, s := range sensors {
|
||||
g := nodeGPU{index: s.Index, name: names[s.Index], util: s.UtilPercent, tempC: s.TempC,
|
||||
memUsedMB: s.MemoryUsedMB, memTotalMB: s.MemoryTotalMB}
|
||||
if s.MemoryTotalMB == 0 {
|
||||
g.util = -1
|
||||
}
|
||||
out = append(out, g)
|
||||
}
|
||||
return out, names, ""
|
||||
}
|
||||
|
||||
// cardOf is the card a device number is on: itself, or a MIG instance's card.
|
||||
func cardOf(i int) int {
|
||||
if discovery.IsMIGIndex(i) {
|
||||
return (i - discovery.MIGIndex(0, 0)) / 100
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// modelsOnGPUs names, on each card, the models whose engines hold it.
|
||||
func modelsOnGPUs(gpus []nodeGPU, runs []engineRun) []nodeGPU {
|
||||
for i := range gpus {
|
||||
gpus[i].models = nil
|
||||
for _, r := range runs {
|
||||
for _, d := range r.gpus {
|
||||
if cardOf(d) == gpus[i].index && !slices.Contains(gpus[i].models, r.model) {
|
||||
gpus[i].models = append(gpus[i].models, r.model)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return gpus
|
||||
}
|
||||
|
||||
// --- the engines ---
|
||||
|
||||
// shardStateFile is the part of the supervisor's shard-*.json the overview
|
||||
// reads (shardState in the supervisor package).
|
||||
type shardStateFile struct {
|
||||
ShardID string `json:"shard_id"`
|
||||
ReplicaID string `json:"replica_id"`
|
||||
PID int `json:"pid"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
Spec engines.ShardSpec `json:"spec"`
|
||||
}
|
||||
|
||||
// readShardStates is the engines the supervisor keeps state files for in
|
||||
// its work directory, with whether each process is still there.
|
||||
func readShardStates(dir string, alive func(int) bool) ([]engineRun, string) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
switch {
|
||||
case errors.Is(err, os.ErrPermission):
|
||||
return nil, "The engines' state is root's: run with sudo."
|
||||
case errors.Is(err, os.ErrNotExist):
|
||||
return nil, ""
|
||||
case err != nil:
|
||||
return nil, err.Error()
|
||||
}
|
||||
var runs []engineRun
|
||||
note := ""
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, "shard-") || !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
b, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if errors.Is(err, os.ErrPermission) {
|
||||
note = "The engines' state is root's: run with sudo."
|
||||
continue
|
||||
}
|
||||
var st shardStateFile
|
||||
if err != nil || json.Unmarshal(b, &st) != nil {
|
||||
continue
|
||||
}
|
||||
replica := st.ReplicaID
|
||||
if replica == "" {
|
||||
replica = st.Spec.ReplicaID
|
||||
}
|
||||
model := st.Spec.Profile.ServedModelName
|
||||
if model == "" {
|
||||
model = modelOfReplica(replica)
|
||||
}
|
||||
runs = append(runs, engineRun{replica: replica, model: model, engine: st.Spec.Profile.Engine,
|
||||
gpus: st.Spec.GPUIndices, port: st.Spec.HTTPPort, pid: st.PID, alive: st.PID > 0 && alive(st.PID),
|
||||
started: st.StartedAt})
|
||||
}
|
||||
sort.Slice(runs, func(i, j int) bool { return runs[i].replica < runs[j].replica })
|
||||
return runs, note
|
||||
}
|
||||
|
||||
// modelOfReplica is the model a replica id names: the control plane calls a
|
||||
// replica the model's name and a suffix.
|
||||
func modelOfReplica(id string) string {
|
||||
if i := strings.LastIndexByte(id, '-'); i > 0 {
|
||||
return id[:i]
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func pidAlive(pid int) bool {
|
||||
err := syscall.Kill(pid, 0)
|
||||
return err == nil || errors.Is(err, syscall.EPERM)
|
||||
}
|
||||
|
||||
// --- the disks ---
|
||||
|
||||
func (p nodeProbe) readDisks() []diskUse {
|
||||
disks := []diskUse{{what: "weights", path: p.weightsDir}, {what: "engines", path: p.enginesDir}}
|
||||
for i := range disks {
|
||||
// In its own goroutine: statfs on a network mount that has gone away
|
||||
// does not return.
|
||||
type res struct {
|
||||
free, total uint64
|
||||
err error
|
||||
}
|
||||
ch := make(chan res, 1)
|
||||
go func(path string) {
|
||||
f, t, err := p.statfs(path)
|
||||
ch <- res{f, t, err}
|
||||
}(disks[i].path)
|
||||
select {
|
||||
case r := <-ch:
|
||||
disks[i].free, disks[i].total = r.free, r.total
|
||||
if r.err != nil {
|
||||
disks[i].err = r.err.Error()
|
||||
if errors.Is(r.err, os.ErrNotExist) {
|
||||
disks[i].err = "not there yet"
|
||||
}
|
||||
}
|
||||
case <-time.After(nodeCmdTimeout):
|
||||
disks[i].err = "does not answer"
|
||||
}
|
||||
}
|
||||
return disks
|
||||
}
|
||||
|
||||
func statfs(path string) (free, total uint64, err error) {
|
||||
var st syscall.Statfs_t
|
||||
if err := syscall.Statfs(path, &st); err != nil {
|
||||
return 0, 0, &os.PathError{Op: "statfs", Path: path, Err: err}
|
||||
}
|
||||
//nolint:unconvert // the field types differ between systems
|
||||
return uint64(st.Bavail) * uint64(st.Bsize), uint64(st.Blocks) * uint64(st.Bsize), nil
|
||||
}
|
||||
|
||||
// --- the problems ---
|
||||
|
||||
func (p nodeProbe) readProblems(ctx context.Context) ([]string, string) {
|
||||
out, err := p.run(ctx, "journalctl", "-u", "llmgw-agent", "-p", "warning", "--since", "-1h",
|
||||
"--no-pager", "-o", "cat", "-n", strconv.Itoa(nodeProblemLines))
|
||||
if err != nil && len(out) == 0 {
|
||||
return nil, "The journal cannot be read: " + err.Error()
|
||||
}
|
||||
lines := parseJournal(string(out), nodeProblemLines)
|
||||
if len(lines) == 0 && !p.root {
|
||||
return nil, "None that this user can see: run with sudo to read the agent's journal."
|
||||
}
|
||||
return lines, ""
|
||||
}
|
||||
|
||||
// parseJournal keeps the last n lines journalctl printed, without its own
|
||||
// remarks.
|
||||
func parseJournal(out string, n int) []string {
|
||||
var lines []string
|
||||
sc := bufio.NewScanner(strings.NewReader(out))
|
||||
sc.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
l := strings.TrimSpace(sc.Text())
|
||||
if l == "" || strings.HasPrefix(l, "-- ") || strings.HasPrefix(l, "Hint: ") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, l)
|
||||
}
|
||||
if len(lines) > n {
|
||||
lines = lines[len(lines)-n:]
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
// runLocal runs a tool on this machine, bounded by nodeCmdTimeout. A tool
|
||||
// that is not installed is said so, not waited for.
|
||||
func runLocal(ctx context.Context, name string, args ...string) ([]byte, error) {
|
||||
if _, err := exec.LookPath(name); err != nil {
|
||||
return nil, fmt.Errorf("%s is not installed", name)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, nodeCmdTimeout)
|
||||
defer cancel()
|
||||
//nolint:gosec // fixed tools and arguments, from this file
|
||||
return exec.CommandContext(ctx, name, args...).Output()
|
||||
}
|
||||
|
||||
// --- the panel ---
|
||||
|
||||
func (m consoleModel) nodeOverviewPanel(pw int) (string, string, string) {
|
||||
keys := "↑↓ choose · PgUp/PgDn scroll · Q leave · refreshed every three seconds"
|
||||
if m.nodeOv.at.IsZero() {
|
||||
return "Overview", "\n " + m.spin.View() + " reading this machine…", keys
|
||||
}
|
||||
return "Overview", renderNodeOverview(m.nodeOv, pw, time.Now()), keys
|
||||
}
|
||||
|
||||
// renderNodeOverview draws the overview from what was read.
|
||||
func renderNodeOverview(ov nodeOverview, pw int, now time.Time) string {
|
||||
var b strings.Builder
|
||||
head := func(s string) { b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render(s) + "\n") }
|
||||
kv := func(k, v string) { fmt.Fprintf(&b, " %-14s %s\n", sDim.Render(k), v) }
|
||||
line := func(s string) { b.WriteString(" " + s + "\n") }
|
||||
|
||||
head("Agent")
|
||||
a := ov.agent
|
||||
switch {
|
||||
case a.note != "":
|
||||
kv("llmgw-agent", sWarn.Render(a.note))
|
||||
default:
|
||||
state := a.state
|
||||
if a.sub != "" {
|
||||
state += " (" + a.sub + ")"
|
||||
}
|
||||
if a.state == "active" {
|
||||
state = sOK.Render(state)
|
||||
} else {
|
||||
state = sBad.Render(state)
|
||||
}
|
||||
if !a.since.IsZero() {
|
||||
state += sDim.Render(" since " + a.since.Format("2006-01-02 15:04") + ", " + uptime(now.Sub(a.since)) + " ago")
|
||||
}
|
||||
kv("llmgw-agent", state)
|
||||
}
|
||||
kv("llmgw", ov.version)
|
||||
|
||||
head("Control planes")
|
||||
if len(ov.planes) == 0 {
|
||||
line(sWarn.Render("None configured: agent.yaml names no server."))
|
||||
}
|
||||
for _, c := range ov.planes {
|
||||
state := sOK.Render("reachable")
|
||||
if c.err != "" {
|
||||
state = sBad.Render("✗ ") + truncate(c.err, max(20, pw-30))
|
||||
}
|
||||
if c.learned {
|
||||
state += sDim.Render(" (named by the leader)")
|
||||
}
|
||||
fmt.Fprintf(&b, " %-22s %s\n", c.addr, state)
|
||||
}
|
||||
|
||||
head("GPUs")
|
||||
if len(ov.gpus) == 0 {
|
||||
line(sDim.Render(ov.gpuNote))
|
||||
} else {
|
||||
var rows [][]string
|
||||
for _, g := range ov.gpus {
|
||||
busy, mem, temp := "—", "—", "—"
|
||||
if g.util >= 0 {
|
||||
busy = fmt.Sprintf("%.0f%%", g.util)
|
||||
}
|
||||
if g.memTotalMB > 0 {
|
||||
mem = fmt.Sprintf("%.1f/%.0f GB", g.memUsedMB/1024, g.memTotalMB/1024)
|
||||
}
|
||||
if g.tempC > 0 {
|
||||
temp = fmt.Sprintf("%.0f°C", g.tempC)
|
||||
}
|
||||
models := strings.Join(g.models, ", ")
|
||||
if models == "" {
|
||||
models = sDim.Render("free")
|
||||
}
|
||||
rows = append(rows, []string{strconv.Itoa(g.index), g.name, busy, mem, temp, models})
|
||||
}
|
||||
b.WriteString(indent(table([]string{"GPU", "CARD", "BUSY", "MEMORY", "TEMP", "MODELS"}, rows, -1, pw-2, false), " ") + "\n")
|
||||
}
|
||||
|
||||
head("Engines running here")
|
||||
switch {
|
||||
case len(ov.engines) > 0:
|
||||
var rows [][]string
|
||||
for _, r := range ov.engines {
|
||||
// The process by its pid, marked when it is no longer there.
|
||||
proc := sOK.Render(strconv.Itoa(r.pid))
|
||||
if !r.alive {
|
||||
proc = sBad.Render(strconv.Itoa(r.pid) + " gone")
|
||||
}
|
||||
gpus := strings.Join(discovery.DeviceLabels(r.gpus), ",")
|
||||
if gpus == "" {
|
||||
gpus = sDim.Render("CPU")
|
||||
}
|
||||
up := "—"
|
||||
if !r.started.IsZero() {
|
||||
up = uptime(now.Sub(r.started))
|
||||
}
|
||||
port := "—"
|
||||
if r.port > 0 {
|
||||
port = strconv.Itoa(r.port)
|
||||
}
|
||||
rows = append(rows, []string{r.replica, r.model, r.engine, gpus, port, proc, up})
|
||||
}
|
||||
b.WriteString(indent(table([]string{"REPLICA", "MODEL", "ENGINE", "GPUS", "PORT", "PID", "UP"}, rows, -1, pw-2, false), " ") + "\n")
|
||||
if ov.enginesNote != "" {
|
||||
line(sWarn.Render(ov.enginesNote))
|
||||
}
|
||||
case ov.enginesNote != "":
|
||||
line(sWarn.Render(ov.enginesNote))
|
||||
default:
|
||||
line(sDim.Render("None: the control plane has placed nothing here."))
|
||||
}
|
||||
|
||||
head("Disk")
|
||||
for _, d := range ov.disks {
|
||||
use := sWarn.Render(d.err)
|
||||
if d.err == "" {
|
||||
use = fmt.Sprintf("%s free of %s", diskSize(d.free), diskSize(d.total))
|
||||
if d.total > 0 && d.free*10 < d.total {
|
||||
use = sWarn.Render(use)
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(&b, " %-14s %-28s %s\n", sDim.Render(d.what), d.path, use)
|
||||
}
|
||||
|
||||
head("Recent problems")
|
||||
switch {
|
||||
case ov.problemsNote != "":
|
||||
line(sDim.Render(ov.problemsNote))
|
||||
case len(ov.problems) == 0:
|
||||
line(sOK.Render("None in the last hour."))
|
||||
default:
|
||||
for _, l := range ov.problems {
|
||||
l = truncateWidth(l, max(20, pw-4))
|
||||
if strings.Contains(l, "level=ERROR") {
|
||||
l = sBad.Render(l)
|
||||
} else {
|
||||
l = sWarn.Render(l)
|
||||
}
|
||||
line(l)
|
||||
}
|
||||
}
|
||||
b.WriteString("\n " + sDim.Render("read at "+ov.at.Format("15:04:05")) + "\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// uptime is a duration as people say it: 3d4h, 2h15m, 40s.
|
||||
func uptime(d time.Duration) string {
|
||||
d = d.Round(time.Second)
|
||||
switch {
|
||||
case d < 0:
|
||||
return "0s"
|
||||
case d >= 24*time.Hour:
|
||||
return fmt.Sprintf("%dd%dh", d/(24*time.Hour), (d%(24*time.Hour))/time.Hour)
|
||||
case d >= time.Hour:
|
||||
return fmt.Sprintf("%dh%dm", d/time.Hour, (d%time.Hour)/time.Minute)
|
||||
case d >= time.Minute:
|
||||
return fmt.Sprintf("%dm%ds", d/time.Minute, (d%time.Minute)/time.Second)
|
||||
}
|
||||
return d.String()
|
||||
}
|
||||
|
||||
// diskSize is a byte count in the unit disks are sold in.
|
||||
func diskSize(n uint64) string {
|
||||
switch {
|
||||
case n >= 1<<40:
|
||||
return fmt.Sprintf("%.1f TB", float64(n)/(1<<40))
|
||||
case n >= 1<<30:
|
||||
return fmt.Sprintf("%.0f GB", float64(n)/(1<<30))
|
||||
}
|
||||
return fmt.Sprintf("%.0f MB", float64(n)/(1<<20))
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
)
|
||||
|
||||
func writeNodeFile(t *testing.T, path, body string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodesOverviewDialsTheConfiguredControlPlanesThenTheLeadersOwn(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
file := filepath.Join(dir, "control-planes.json")
|
||||
writeNodeFile(t, file, `["https://10.0.0.150:7443","10.0.0.140:7443","https://cp3.example/agent"]`)
|
||||
got := readControlPlanes(file, []string{"10.0.0.140:7443", "http://10.0.0.141"})
|
||||
want := []planeCheck{{addr: "10.0.0.140:7443"}, {addr: "10.0.0.141:80"},
|
||||
{addr: "10.0.0.150:7443", learned: true}, {addr: "cp3.example:443", learned: true}}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %+v, want %+v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("%d: got %+v, want %+v", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
// Without the file, what agent.yaml says.
|
||||
if got := readControlPlanes(filepath.Join(dir, "none.json"), []string{"cp:7443"}); len(got) != 1 || got[0].addr != "cp:7443" {
|
||||
t.Errorf("without the file: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodesOverviewReadsTheEnginesFromTheSupervisorsStateFiles(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
writeNodeFile(t, filepath.Join(dir, "shard-qwen3-8b-ab12-0.json"), `{"shard_id":"qwen3-8b-ab12-0","replica_id":"qwen3-8b-ab12",
|
||||
"pid":4242,"started_at":"2026-10-19T08:00:00Z","spec":{"ReplicaID":"qwen3-8b-ab12","GPUIndices":[0,1],"HTTPPort":8101,
|
||||
"Profile":{"Name":"vllm-tp2","Engine":"vllm","ServedModelName":"qwen3-8b"}}}`)
|
||||
writeNodeFile(t, filepath.Join(dir, "shard-bge-m3-7-0.json"), `{"replica_id":"bge-m3-7","pid":99,
|
||||
"spec":{"GPUIndices":[1002],"HTTPPort":8102,"Profile":{"Engine":"llamacpp"}}}`)
|
||||
writeNodeFile(t, filepath.Join(dir, "shard-x.json.tmp"), `{"replica_id":"half-written"}`)
|
||||
writeNodeFile(t, filepath.Join(dir, "control-planes.json"), `[]`)
|
||||
runs, note := readShardStates(dir, func(pid int) bool { return pid == 4242 })
|
||||
if note != "" {
|
||||
t.Fatalf("note %q", note)
|
||||
}
|
||||
if len(runs) != 2 {
|
||||
t.Fatalf("got %+v", runs)
|
||||
}
|
||||
b, q := runs[0], runs[1]
|
||||
if q.replica != "qwen3-8b-ab12" || q.model != "qwen3-8b" || q.engine != "vllm" || q.port != 8101 || !q.alive ||
|
||||
len(q.gpus) != 2 || !q.started.Equal(time.Date(2026, 10, 19, 8, 0, 0, 0, time.UTC)) {
|
||||
t.Errorf("qwen: %+v", q)
|
||||
}
|
||||
// No served name: the model from the replica's id.
|
||||
if b.model != "bge-m3" || b.alive || b.engine != "llamacpp" {
|
||||
t.Errorf("bge: %+v", b)
|
||||
}
|
||||
if runs, note := readShardStates(filepath.Join(dir, "absent"), pidAlive); runs != nil || note != "" {
|
||||
t.Errorf("no work dir: %+v %q", runs, note)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodesOverviewNamesTheModelOnEachGPU(t *testing.T) {
|
||||
gpus := []nodeGPU{{index: 0}, {index: 1}, {index: 2}}
|
||||
runs := []engineRun{
|
||||
{model: "qwen3-8b", gpus: []int{0, 1}},
|
||||
{model: "bge-m3", gpus: []int{discovery.MIGIndex(1, 2)}},
|
||||
{model: "qwen3-8b", gpus: []int{1}},
|
||||
}
|
||||
got := modelsOnGPUs(gpus, runs)
|
||||
if strings.Join(got[0].models, ",") != "qwen3-8b" || strings.Join(got[1].models, ",") != "qwen3-8b,bge-m3" ||
|
||||
len(got[2].models) != 0 {
|
||||
t.Errorf("got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodesOverviewReadsWhatSystemdSaysOfTheAgent(t *testing.T) {
|
||||
s := parseSystemctlShow("LoadState=loaded\nActiveState=active\nSubState=running\nActiveEnterTimestamp=Mon 2026-10-19 08:12:03 UTC\n")
|
||||
if s.state != "active" || s.sub != "running" || s.note != "" || s.since.IsZero() || s.since.Minute() != 12 {
|
||||
t.Errorf("got %+v", s)
|
||||
}
|
||||
if s := parseSystemctlShow("LoadState=not-found\nActiveState=inactive\nActiveEnterTimestamp=\n"); s.note == "" {
|
||||
t.Errorf("a missing service: %+v", s)
|
||||
}
|
||||
if s := parseSystemctlShow("LoadState=loaded\nActiveState=failed\nSubState=failed\nActiveEnterTimestamp=n/a\n"); s.state != "failed" ||
|
||||
!s.since.IsZero() {
|
||||
t.Errorf("a failed service: %+v", s)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodesOverviewKeepsTheLastWarningsOfTheJournal(t *testing.T) {
|
||||
var b strings.Builder
|
||||
for i := 1; i <= 9; i++ {
|
||||
b.WriteString("level=WARN msg=w" + string(rune('0'+i)) + "\n")
|
||||
}
|
||||
got := parseJournal(b.String(), 6)
|
||||
if len(got) != 6 || got[0] != "level=WARN msg=w4" || got[5] != "level=WARN msg=w9" {
|
||||
t.Errorf("got %q", got)
|
||||
}
|
||||
if got := parseJournal("-- No entries --\n", 6); len(got) != 0 {
|
||||
t.Errorf("no entries: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// fakeProbe is a node with two cards, one engine and a broken second control
|
||||
// plane, and no tool of the machine running the test touched.
|
||||
func fakeProbe(t *testing.T) nodeProbe {
|
||||
dir := t.TempDir()
|
||||
writeNodeFile(t, filepath.Join(dir, "shard-qwen3-8b-ab12-0.json"), `{"replica_id":"qwen3-8b-ab12","pid":4242,
|
||||
"started_at":"2026-10-19T08:00:00Z","spec":{"GPUIndices":[0],"HTTPPort":8101,"Profile":{"Engine":"vllm","ServedModelName":"qwen3-8b"}}}`)
|
||||
return nodeProbe{
|
||||
configured: []string{"10.0.0.140:7443", "10.0.0.150:7443"},
|
||||
planesFile: filepath.Join(dir, "control-planes.json"),
|
||||
workDir: dir, weightsDir: "/var/lib/llmgw-weights", enginesDir: "/opt/llmgw/engines", root: true,
|
||||
run: func(_ context.Context, name string, _ ...string) ([]byte, error) {
|
||||
switch name {
|
||||
case "systemctl":
|
||||
return []byte("LoadState=loaded\nActiveState=active\nSubState=running\nActiveEnterTimestamp=Mon 2026-10-19 08:00:00 UTC\n"), nil
|
||||
case "journalctl":
|
||||
return []byte("level=WARN msg=\"the control plane does not answer\" server=10.0.0.150:7443\n"), nil
|
||||
}
|
||||
return nil, errors.New(name + " is not installed")
|
||||
},
|
||||
dial: func(_ context.Context, addr string) error {
|
||||
if addr == "10.0.0.150:7443" {
|
||||
return errors.New("dial tcp 10.0.0.150:7443: connect: connection refused")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
gpus: func(context.Context) []discovery.GPUSensor {
|
||||
return []discovery.GPUSensor{
|
||||
{Index: 0, TempC: 61, UtilPercent: 87, MemoryUsedMB: 20480, MemoryTotalMB: 24576},
|
||||
{Index: 1, TempC: 35, UtilPercent: 0, MemoryUsedMB: 300, MemoryTotalMB: 24576},
|
||||
}
|
||||
},
|
||||
cards: func(context.Context) ([]discovery.GPU, error) {
|
||||
return []discovery.GPU{{Index: 0, Name: "NVIDIA RTX A5000"}, {Index: 1, Name: "NVIDIA RTX A5000"}}, nil
|
||||
},
|
||||
alive: func(int) bool { return true },
|
||||
statfs: func(string) (uint64, uint64, error) { return 120 << 30, 500 << 30, nil },
|
||||
now: func() time.Time { return time.Date(2026, 10, 19, 11, 12, 0, 0, time.UTC) },
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodesOverviewShowsWhatRunsOnTheMachine(t *testing.T) {
|
||||
ov := fakeProbe(t).gather(context.Background())
|
||||
out := ansi.Strip(renderNodeOverview(ov, 100, ov.at))
|
||||
t.Log("\n" + out)
|
||||
for _, want := range []string{
|
||||
"llmgw-agent", "active (running)", "3h12m ago",
|
||||
"10.0.0.140:7443", "reachable", "connection refused",
|
||||
"NVIDIA RTX A5000", "87%", "20.0/24 GB", "61°C", "qwen3-8b", "free",
|
||||
"qwen3-8b-ab12", "vllm", "8101", "4242", "3h12m",
|
||||
"/var/lib/llmgw-weights", "120 GB free of 500 GB", "/opt/llmgw/engines",
|
||||
"the control plane does not answer",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("the overview lacks %q", want)
|
||||
}
|
||||
}
|
||||
if ov.names[0] != "NVIDIA RTX A5000" {
|
||||
t.Errorf("the names are not kept for the next reading: %v", ov.names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodeWithoutAGPUSaysSoInOneLine(t *testing.T) {
|
||||
p := fakeProbe(t)
|
||||
p.gpus = func(context.Context) []discovery.GPUSensor { return nil }
|
||||
p.cards = func(context.Context) ([]discovery.GPU, error) { return nil, nil }
|
||||
p.run = func(context.Context, string, ...string) ([]byte, error) { return nil, errors.New("not installed") }
|
||||
ov := p.gather(context.Background())
|
||||
out := ansi.Strip(renderNodeOverview(ov, 100, ov.at))
|
||||
if !strings.Contains(out, "No GPU on this machine.") {
|
||||
t.Errorf("no line saying there is no GPU:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "BUSY") {
|
||||
t.Errorf("an empty GPU table:\n%s", out)
|
||||
}
|
||||
// No systemctl or journalctl is said, not a crash.
|
||||
if !strings.Contains(out, "systemctl is not available") || !strings.Contains(out, "The journal cannot be read") {
|
||||
t.Errorf("missing tools are not said:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestANodesConsoleOpensOnItsOverview(t *testing.T) {
|
||||
m := newConsoleModel("127.0.0.1:1", filepath.Join(t.TempDir(), "agent.yaml"))
|
||||
if m.menu != cOverview || !m.entryOK(cOverview) {
|
||||
t.Fatalf("menu %d", m.menu)
|
||||
}
|
||||
_, body, _ := m.panel()
|
||||
if !strings.Contains(body, "reading this machine") {
|
||||
t.Errorf("before the first reading: %q", body)
|
||||
}
|
||||
}
|
||||
@@ -395,6 +395,7 @@ func (m *consoleModel) limitsForm(scope store.Scope, id, who string) {
|
||||
if l.AllowedModels != nil {
|
||||
models = strings.Join(l.AllowedModels, ",")
|
||||
}
|
||||
tools := strings.Join(l.AllowedTools, ",")
|
||||
m.openForm(&formDialog{title: "Limits on " + who,
|
||||
note: "Each level only narrows what is above it. Empty is no limit here.",
|
||||
fields: []formField{
|
||||
@@ -403,9 +404,11 @@ func (m *consoleModel) limitsForm(scope store.Scope, id, who string) {
|
||||
textF("budget", budget, "spend per period, in whole units"),
|
||||
choiceF("per", []string{"day", "month"}, period),
|
||||
textF("models", models, "allowed, separated by commas; \"every model\" for no list"),
|
||||
textF("company tools", tools, "MCP tools granted here, as jira.*,db.query; empty grants none here"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
args := []string{"keys", "limits", "--scope=" + string(scope), "--id=" + id}
|
||||
// Every field the form shows, so an emptied one is cleared.
|
||||
args := []string{"keys", "limits", "--replace", "--scope=" + string(scope), "--id=" + id}
|
||||
for i, flag := range []string{"rpm", "tpm"} {
|
||||
if v[i] != "" {
|
||||
if _, err := strconv.Atoi(v[i]); err != nil {
|
||||
@@ -423,6 +426,9 @@ func (m *consoleModel) limitsForm(scope store.Scope, id, who string) {
|
||||
if v[4] != "every model" {
|
||||
args = append(args, "--models="+strings.ReplaceAll(v[4], " ", ""))
|
||||
}
|
||||
if t := strings.ReplaceAll(v[5], " ", ""); t != "" {
|
||||
args = append(args, "--tools="+t)
|
||||
}
|
||||
return m.runCLI("Limits on "+who, args...), nil
|
||||
}})
|
||||
}
|
||||
@@ -567,13 +573,20 @@ func (m consoleModel) orgMenu(r orgRow) *menuDialog {
|
||||
if c.Any {
|
||||
which = "any"
|
||||
}
|
||||
similar := "off"
|
||||
if c.Similar > 0 {
|
||||
similar = strconv.FormatFloat(c.Similar, 'f', -1, 64)
|
||||
}
|
||||
m.openForm(&formDialog{title: "Cache answers · " + r.path,
|
||||
note: "Off by default. On, the same request to the same model within the time below is\n" +
|
||||
"answered again from the gateway's memory, without the model, and costs nothing.\n" +
|
||||
"Reproducible: only temperature 0 or a seed, and embeddings. Any: sampled answers too.",
|
||||
"Reproducible: only temperature 0 or a seed, and embeddings. Any: sampled answers too.\n" +
|
||||
"Similar: a chat question this alike to one answered gets its answer (needs\n" +
|
||||
"router.cache.embedding_model).",
|
||||
fields: []formField{
|
||||
textF("reuse for", ttl, "like 1h; off reuses none"),
|
||||
choiceF("answers", []string{"reproducible", "any"}, which),
|
||||
textF("similar", similar, "like 0.95; off reuses only the same request"),
|
||||
},
|
||||
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
|
||||
args := []string{"keys", "cache", "--project=" + r.id}
|
||||
@@ -587,6 +600,12 @@ func (m consoleModel) orgMenu(r orgRow) *menuDialog {
|
||||
if v[1] == "any" {
|
||||
args = append(args, "--any")
|
||||
}
|
||||
if v[2] != "off" && v[2] != "" && v[2] != "0" {
|
||||
if f, err := strconv.ParseFloat(v[2], 64); err != nil || f <= 0 || f > 1 {
|
||||
return nil, fmt.Errorf("similar is a number like 0.95, or off")
|
||||
}
|
||||
args = append(args, "--similar="+v[2])
|
||||
}
|
||||
return m.runCLI("Cache answers", args...), nil
|
||||
}})
|
||||
return nil
|
||||
@@ -761,5 +780,8 @@ func limitsCell(l store.Limits) string {
|
||||
if l.AllowedModels != nil {
|
||||
p = append(p, fmt.Sprintf("%d models", len(l.AllowedModels)))
|
||||
}
|
||||
if len(l.AllowedTools) > 0 {
|
||||
p = append(p, fmt.Sprintf("%d tool grants", len(l.AllowedTools)))
|
||||
}
|
||||
return strings.Join(p, ", ")
|
||||
}
|
||||
Loaded 100 of 324 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user