Compare commits

...
25 Commits
Author SHA1 Message Date
Ilker ManapandClaude Opus 5.5 7eb50c5eb6 v0.14.6: changelog and README
CI / test (push) Failing after 30m6s
Release / build (push) Successful in 17m32s
Release / notes (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 13:33:13 +02:00
Ilker ManapandClaude Opus 5.5 b8fc1de822 Backlog I: what other gateways do that llmgw does not
CI / test (push) Failing after 30m6s
From a comparison with Bifrost: answers to similar questions, the
customer's MCP servers behind llmgw, the gateway's overhead measured, and
several keys for one outside provider. H9's /mcp item is done.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:29:20 +02:00
Ilker ManapandClaude Opus 5.5 63b2c6d9a4 The judges and the starter divide a machine's CPUs instead of each taking all
CI / test (push) Failing after 30m7s
Measured on the three-VM cluster with ha1's CPUs loaded: 38 of 60 requests
refused with both judges and the starter on ha1, each engine on all four
cores. With the judges moved to a 4-core, 32 GB node at 2 cores each, 2 of
60 were refused, both the first after the judges started.

init now reserves cores by this machine's count: one per judge replica
under 8 CPUs and two from 8, and the rest, up to 8, for the starter on CPU.
The starter reserves nothing when nothing is left, so it still fits. Judges
bound for nodes not yet known get two each. A judge reserves 4 GB; it uses
about 3.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 13:00:39 +02:00
Ilker ManapandClaude Opus 5.5 0b984a92bc A judge is asked again only on another machine
CI / test (push) Failing after 30m3s
Measured under CPU load on the three-VM cluster, with both judge replicas on
ha1: 37 of 60 requests failed with the hedge, 38 of 60 without it. Two
replicas on one saturated machine are slow together, and the second
question doubled the work there. The hedge now goes only to a replica whose
address names another host.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 10:27:50 +02:00
Ilker ManapandClaude Opus 5.5 bc4c5d5ac9 A judge replica that hangs is overtaken by another at half the budget
On the three-VM cluster about one answer in twenty-five came back as
content_filter: both judge replicas timed out within the same millisecond.
The tries shared the policy's 5 s budget, so the first replica, busy on an
oversubscribed machine, used it all and the retry on the second had nothing
left. A judge request now goes to a second replica too when the first has
not answered by half the budget, and the first answer is taken. The first
is not cut short.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 08:35:28 +02:00
Ilker ManapandClaude Opus 5.5 b09922b29b Leadership stays put while the store's primary is replaced
CI / test (push) Failing after 30m3s
Measured again after the store fixes. Cutting the store primary's power no
longer hung requests, but leadership moved anyway: the lease had lapsed for
everyone while Patroni promoted the standby, and a standby asked first. The
new leader then spent about 25 s without its nodes, and so without its
judges.

A standby that saw the store out of reach now leaves the lease alone for one
renew round after the store returns. The default lease is 10 s, and a node
detects a control plane that died silently in 8 s (5 s idle, 3 s ping)
rather than 15.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 23:17:58 +02:00
Ilker ManapandClaude Opus 5.5 5a4b022dcd A lost store machine is given up on in seconds, and a new leader waits for its judge
CI / test (push) Failing after 30m6s
Measured on three VMs: cutting the store primary's power cost about 45 s of
hanging requests. New connections tried the dead member first with no
connect timeout, and Patroni kept its 30 s leader key. Cutting the leader of
both cost a further 17 s of 503s while the new leader knew no judge.

The store dials each member for 3 s and closes connections to a machine that
is gone within about 10 s (keepalive, TCP_USER_TIMEOUT). Patroni runs with
ttl 20, loop_wait 5 and retry_timeout 5, applied in place to existing
clusters through its API. Judges that all fail at once are asked again
every 250 ms while the policy's budget lasts, and reported once.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 20:33:13 +02:00
Ilker ManapandClaude Opus 5.5 eeaffd764d A floating address keeps to its own network, and Caddy follows the leader
CI / test (push) Failing after 30m3s
Found by a three-machine install on fresh VMs. The second standby's run
asked for the floating address's interface with `ip route get <vip>`.
The machine already held the address, so that answered lo, and keepalived
would not start. The interface now comes from the route to the address's
network. Every cluster's VRRP router id was 51, so the PoC's two control
planes and the new three on one LAN each logged the other's adverts as an
invalid password; the id now comes from the address.

The Caddyfile, deploy/ha and install.md send clients to the control plane
whose /leader is 200, not /ready: /ready is 503 on a leader with no model
yet and would take the portal away with it. Setup no longer hides a key
file's path as if it were a password, and the starter's text no longer
says it runs elsewhere when this machine is a node.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 17:35:10 +02:00
Ilker ManapandClaude Opus 5.5 94401e21e6 Setup lets every part be chosen, each with its settings; three machines each run the gateway
CI / test (push) Failing after 30m4s
The setup screens list the parts of an install: control plane, store, model
mirror, this machine as a node, starter model, pictures and 3D, the judge,
firewall, notifications, limits, on-demand turns and an offline bundle. Each
says what it is or what is missing without it. Space turns a part on or off;
Enter opens its settings in a dialog that explains it. A plan page lists what
is installed and what is left out, and the same install as a command. Every
setting is a server init flag too, written into server.yaml when init writes
it.

With three machines, the two database machines become control planes too,
standing by (server add's steps), with an optional floating address. The
mirror's management API answers all three control planes, so a standby that
comes to lead can still fetch models.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 11:22:36 +02:00
Ilker Manap 733e20924c Merge remote-tracking branch 'origin/mcp-wait' into sd-dedupe
CI / test (push) Failing after 30m3s
2026-10-15 07:53:22 +02:00
Ilker ManapandClaude Opus 5.5 fc60481e40 Every control plane lists the others alike to Prometheus
140 named 150 a store member, 150 named itself a control plane: Prometheus
asking both scraped 150 twice and counted its every series twice, and asked
of 140 alone while 150 led it lost every node. Each control plane now lists
all of them as control planes before the store's members, so two answers
agree and a target is scraped once. The shipped prometheus.yml says to
scrape and ask every control plane.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 07:53:22 +02:00
Ilker ManapandClaude Opus 5.5 edeeb9f8e1 MCP takes pictures of real size, by id too, and waits for a cold model
The game's bridge found three things on the PoC. /mcp held its body to
64 KiB — the edit meant for it had raised the memory notes' limit instead —
so a 1024 px concept sent as base64 failed as "expected one JSON-RPC
request object". The notes are back to 64 KiB, /mcp takes 64 MB, and a
call past it is a 413 saying so.

/v1/files took batch files only, so a picture could not be sent once and
named after. purpose vision and user_data are kept too, as long as job
results, and generate_mesh hands a workflow model the id as it is.

generate_image and review_images failed at once while their model was
cold. They wait out an answer that says when to come back — loading,
another model holding the GPU, a full queue — for as long as the call may,
saying who holds the GPU and about how long; a refusal that says no
Retry-After, a spent budget, is said at once.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:24:59 +02:00
Ilker ManapandClaude Opus 5.5 c22528c26e A model that does not fit its card is not added to fail; weights in use are not removed
CI / test (push) Failing after 30m3s
Apertus 70B with a 65536-token pool, and then 8B with 131072, on a 24 GB
card loaded their weights and failed to make their context on every start,
the 8B taking the card from the 70B each time only to fail. ctl model serve
now shrinks a pool the card cannot hold beside the weights (KV cache from
the GGUF's shape, 90% of the card, a gigabyte for the engine), saying so,
and refuses weights that do not fit at all; the portal's serve form refuses
such a pool and says the largest that fits.

The mirror page removed the 70B's weights while the catalog still had the
model, whose replicas went on loading the node's copy with nothing said.
Removing weights a catalog model loads is refused, naming the model.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-15 00:22:01 +02:00
Ilker Manap bda988b78f Merge remote-tracking branch 'origin/setup-webhooks' into shared-replica
CI / test (push) Successful in 9m54s
# Conflicts:
#	CHANGELOG.md
2026-10-14 14:26:27 +02:00
Ilker Manap a7cb3aac3c Merge remote-tracking branch 'origin/mcp-media' into shared-replica
# Conflicts:
#	CHANGELOG.md
2026-10-14 14:01:24 +02:00
Ilker ManapandClaude Opus 5.5 d1e91868ee Setup asks where alerts go, and sends each a test
notify.webhooks existed and setup never asked for one, so a new site
learnt of a parked model or a lost node from its users. `llmgw server
init --webhook URL` (repeatable) and a Notification webhooks field on
both setup screens take them now. Slack's address, Mattermost's and
Rocket.Chat's /hooks/ and Discord's …/slack are sent Slack's text, any
other the event as JSON; slack: or json: in front says which.

Once the control plane runs, they are added to the cluster's settings
beside the webhooks already there, none twice, and setup waits for the
control planes to start again on the change before sending each a test,
saying which answered. A webhook's path is its secret, so it is shown by
host only. A failure is said, not fatal: the cluster serves without its
alerts, and ctl notify test tries again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:55:48 +02:00
Ilker ManapandClaude Opus 5.5 aa531b1053 ComfyUI models of one environment share one replica
flux and sf3d on one card handed it to each other, each starting ComfyUI
and loading its weights from nothing (BACKLOG H3, placement side). An
environment of two or more models now gets a hidden catalog entry,
comfyui-env-<name>: on its first member's profiles with the most memory
any member needs, on demand for the longest idle timeout, carrying every
member's weights, repositories and the environment's custom nodes. The
members get no replicas of their own; asking for one wants the entry.

The agent fetches each member's weights; the gate links all of them,
refusing two weights files of one name, links each member's Hugging Face
repository, and answers /v1/models for every member. The control plane
offers the replica under the members' names with each one's workflow and
checkpoint, which the router sends by the model asked for. A member's
sibling never evicts the replica serving it, and the members' load counts
as the entry's.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 13:55:34 +02:00
Ilker ManapandClaude Opus 5.5 55d5e13534 Pictures, 3D models, a vision model and workflows as tools at /mcp
An agent driving Blender made its concepts, meshes and render reviews
through a tool server of the game's own, which called this gateway; the
gateway's MCP server offered only the memory notes. It now offers
generate_image, generate_mesh, review_images, run_workflow, job_status and
gateway_status beside them, each only where a model for it is here.

Each tool makes its request of the client API as the caller, through the
handler Handler built, key check and all: allowlists, limits, licences
left out, budgets and accounting apply as to any request, and a job is
the caller's. What a tool makes is kept as the caller's file and answered
by id and download URL, since a server over HTTP cannot write to the
caller's disk; a picture comes with a 512 px preview, and whole where no
file can be kept. A picture to work from is a file id, a data URL or
base64; one sent to the vision model is shrunk to 1280 px.

A mesh takes minutes. A client that takes a stream is answered as
server-sent events, with notifications/progress for its token while the
job is queued, waiting for the GPU and running, and waits up to ten
minutes; one that does not waits 50 seconds and gets the job id, which
job_status asks after.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:11:09 +02:00
Ilker ManapandClaude Opus 5.5 7c53380e68 Extended thinking on /v1/messages: a model's reasoning as thinking blocks
CI / test (push) Successful in 9m12s
Claude Code and the Anthropic SDKs asking for extended thinking got the
answer alone: a reasoning model's reasoning_content was dropped. Asked for,
it comes back as a thinking block before the text — streamed as
thinking_delta events and closed with a signature before the text begins —
and the model is told to think through its chat template
(enable_thinking); disabled tells it not to. Not asked for, it is left out
as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 11:05:02 +02:00
Ilker ManapandClaude Opus 5.5 0460f52dd2 Jobs run side by side, one per model at a time
CI / test (push) Successful in 10m28s
One job at a time, a 3D job waiting for the card held up another model's
job that could have run (seen on the PoC, BACKLOG H7). The leader's worker
claims jobs while fewer than eight run, passing over the models that have
one running, and starts each in its own goroutine; one ending wakes it for
the next. Lease and leader-only rules are as they were.

BACKLOG: H5–H7 marked, H8 (presets) and H9 (what the PoC taught) written.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 10:06:20 +02:00
Ilker ManapandClaude Opus 5.5 efffdb66b7 An embedding model is served for embedding, with many slots
ctl model serve served one as a chat model: no --embedding, four slots, a
512-token batch, embeddings unset, and a chat smoke test it could not
pass. A GGUF that pools (<arch>.pooling_type), or --embeddings, gets
--embedding, 32 slots of 1024 tokens unless sized otherwise, a batch that
holds a whole passage, the embeddings capability, and one vector asked for
as its smoke test (canaries and updates too). An import of a hundred
thousand chunks had gone at the pace of four slots with the card a third
busy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 09:19:57 +02:00
Ilker ManapandClaude Opus 5.5 f5eb6963af An upgrade that leaves nodes behind says which
A site's nodes stayed a release behind after its control plane was
upgraded, with nothing printed to say so. ctl upgrade checks every node's
version once the waves are done, fails naming any not on the release, and
says when the control plane has no nodes registered. llmgw upgrade on a
control plane, which looks like the cluster's upgrade and is not, says how
many nodes it left behind and the command that upgrades them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 09:01:09 +02:00
Ilker ManapandClaude Opus 5.5 1e7086e13e How long an on-demand model holds a card is a live setting
On one card shared by flux and sf3d, a mesh asked for after a picture
waited out the picture model's five minutes of protection and two of idle.
on_demand.evict_after_idle and on_demand.min_lifetime set those for the
cluster, in force on the leader as they change; unset keeps 2m and 5m.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:58:38 +02:00
Ilker ManapandClaude Opus 5.5 a9a2360f44 pages.js is loaded by its versioned address
CI / test (push) Successful in 11m15s
Five pages named /_static/pages.js outright, cached for a day, so a
browser kept the old script after an upgrade — the one holding refreshes
off while a form is filled in among them. They take it through asset, as
the rest do, whose address changes with its contents.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:15:44 +02:00
Ilker ManapandClaude Opus 5.5 e78075876b A form being filled in is not refreshed away
CI / test (push) Failing after 7m30s
The cluster and node pages swap their live part every few seconds, and the
forms inside it went with it: what was typed, an open choice, the focus.
Filling one in raced the clock. The swap now waits while a field in the
live part has the focus or a form in it has been typed into and not sent;
sending it, or leaving the page, ends that. The mirror page's reload
during a download goes through the same check instead of http-equiv.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 08:13:08 +02:00
110 changed files with 6726 additions and 542 deletions

No files matched your search

+102 -3
View File
@@ -1385,7 +1385,7 @@ gate, so no token is on the node.
- Done when: SF3D runs on cuda01 with nothing installed into the engine's
venv by hand (the hand install of 2026-10-10 removed first).
### H5. Licence on every output (S)
### H5. Licence on every output (S) — done 2026-10-14: the whole licence in GLB asset.extras and PNG iTXt; video and audio not yet
- `License` gains `id` (SPDX where there is one, else a lowercase name:
stability-community, tencent-hunyuan-community), `commercial` (unset is
not said), `excluded_regions` (ISO 3166 codes, `EU` for the union),
@@ -1398,7 +1398,7 @@ gate, so no token is on the node.
audio formats in their metadata where ffmpeg is at hand on the router,
otherwise beside it in the job's answer only — said in the docs.
### H6. Licence choices (S) — the other session's (llmgw-b7), at the user's request
### H6. Licence choices (S) — done 2026-10-11 (4bbac08), live on the PoC
The user asked (2026-10-11) for the choice to be a person's: on their
profile they pick the licences they will not use, and their keys and
portal session neither see those models in `/v1/models` nor may call
@@ -1407,13 +1407,112 @@ them (403, saying which licence). Matched on `license.id`, and on
store table is migration 0046. A site-wide or team-wide default can sit
on top of it later.
### H7. A job worker per model, not one for all (S)
### H7. A job worker per model, not one for all (S) — done 2026-10-14: one job per model at a time, eight in all
The worker runs one job at a time: a job waiting for the card holds up a
job for another model that could run now (seen on the PoC, 2026-10-10).
Jobs are claimed and run concurrently up to a limit per model (or per
environment, after H3) and a cluster-wide cap, with the lease and
leader-only rules of G1 kept.
### H8. Presets that add themselves (S) — done 2026-10-13 (v0.14.5)
flux-schnell and triposr come with llmgw (`ctl model add <preset>`), and
the leader adds them on demand where a node fits one, the cluster installs
ComfyUI and the catalog makes nothing of that kind (`presets.auto`); a
preset removed is declined and not added back. The cluster page and the
console show and add them.
### H9. Small things learned on the PoC (2026-10-14)
- Done: `albedo_match` on sf3d (its delighting made dark objects light);
`on_demand.evict_after_idle` / `min_lifetime` as live settings; an
upgrade that leaves nodes behind says which; `ctl model serve` serves an
embedding model for embedding; refreshes wait while a form is filled in.
- Done 2026-10-15: llmgw's `/mcp` with picture, 3D, VLM and workflow tools,
so a client needs no server of its own (asked by the game team); the
shared ComfyUI replica (H3's placement side).
## I. What other gateways do that llmgw does not (written 2026-10-16)
From a comparison with Bifrost (maximhq/bifrost, Apache 2.0, Go), with
LiteLLM and Portkey in mind. Bifrost routes to 23+ cloud providers and runs
no model; llmgw runs the models, so most of Bifrost is either in llmgw
already (keys, budgets, fallbacks, guardrails, tracing, SSO, HA — the last
three paid in Bifrost) or does not apply. These four are worth taking for
a site that runs its own models. Left until a customer asks: native
Azure/Anthropic/Bedrock/Vertex adapters, request hooks a customer plugs in,
routing by what a request holds. Not taken: a realtime (WebSocket audio)
API, prompt management, an `npx` install.
### I1. Answers to similar questions, not only the same one (M)
The answer cache (`internal/router/cache.go`, C17) answers a request whose
body is the same byte for byte. A help desk asked "how do I reset my
password" in forty wordings asks the model forty times.
- Per project, off by default, beside the exact cache: `llmgw keys cache
--project P --similar 0.95` (the cosine similarity a question must reach;
higher is stricter). The exact cache is asked first.
- The question embedded by a model the operator names
(`router.cache.embedding_model`, an embedding model in the catalog), into a
pgvector table per project with the answer, the model and the TTL; the
store's halfvec and text index work from F applies. SQLite stores keep the
exact cache only.
- What counts as the question: the last user turn, with the system prompt
and the earlier turns hashed and required to match exactly — a similar
question in another conversation is not the same question. Tools,
`response_format` and the sampling settings also exact.
- Said: `x-llmgw-cache: similar` with the similarity, and the usage row
says so; guardrails judge the request before a hit, as for the exact
cache. A project with `no_store` gets neither.
- Done when: forty rewordings of one question on the PoC cost one model call
and 39 lookups, and an eval suite run with the cache on scores within a
point of the run with it off.
### I2. MCP gateway: the customer's tool servers behind llmgw (L)
`/mcp` serves llmgw's own tools. An agent inside a company also wants Jira,
the file server, a database — each an MCP server, each today configured
in every client with its own credentials and nobody seeing what was called.
- Servers registered once, by an administrator: `llmgw ctl mcp add jira
--url https://… --header-file /etc/llmgw/secrets/jira` (HTTP transport;
stdio servers run on the control plane under their own user, like an
engine). Their credentials stay on the control plane, never in a client.
- `/mcp` lists every server's tools, prefixed by its name (`jira.search`),
filtered by what the key may use: an allowlist per org, team, project or
key, by server or by tool, in the tenancy tree as model allowlists are.
- Each call goes to the audit log — who, which tool, the arguments' size,
the outcome and how long — and through the guardrails both ways (a tool's
answer is prompt input). A per-tool rate limit.
- Later, if it earns it: Bifrost's "code mode" (the tools as one typed API
the model writes code against), which cuts the tokens of a long tool list.
- Done when: Claude Code given only llmgw's `/mcp` reaches a test MCP server
through it, a key without the grant does not see the server's tools, and
every call is in `ctl audit`.
### I3. What llmgw adds to a request, measured (S)
Bifrost's headline is "11 µs at 5k RPS" (its own figure, and its own pages
disagree with it). llmgw has none, so any question about its overhead is
answered with a guess.
- `llmgw ctl bench --overhead`: the same load through the gateway and straight
at a `sim-engine` (`internal/cli/simengine.go`) that answers at once, at
rising request rates, with auth, accounting and the guardrails' pattern
checks on and off. Reports the added latency at p50/p99 and the gateway's
CPU per thousand requests.
- On the PoC the CPU is oversubscribed (pve1, 28 threads for ~125 vCPUs):
report it as a ratio and record the host's load beside it, and repeat on
an idle machine before a figure goes into the README.
- Done when: the README states the overhead, with how it was measured, and
CI runs the short version so a change that doubles it is seen.
### I4. Several keys for one outside provider (S)
An external model has one `api_key_file` (`internal/control/control.go`,
where external endpoints are made). A provider's limit is per key; one key
answering 429 makes the whole fallback answer 429.
- `api_key_files: [a, b, c]`, each with an optional `weight`: one endpoint
per key (`external:<model>#<n>`), so the router's scoring, retries and
breakers work per key as they already do per replica. A key answering 429
or 401 is passed over while its breaker is open; a 401 also notifies
(`external_key_refused`).
- The usage row says which key answered (by its index, never the key).
- Done when: with two keys and one returning 429, every request is answered
by the other, and the console shows the one breaker open.
## D. Facts worth knowing before starting
- Admin API: `127.0.0.1:9090`, no auth, `ctlClient` in `internal/cli/ctl.go`.
+156 -2
View File
@@ -18,13 +18,167 @@ 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.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) |
| 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.
## Unreleased
## v0.14.6 — 2026-10-16
Setup that says what each part of an install is and lets every one be
chosen and set; three machines that each run the gateway, so any one can
be lost; a cluster that loses its store's primary without losing a
request; one ComfyUI for an environment's models; pictures, 3D and
workflows as MCP tools; alerts asked for at setup; extended thinking.
**Upgrading.**
- No migration.
- **Control planes**: with `control.lease_ttl` unset the lease is now 10 s
(was 15). Upgrade the standbys first and the leader last; a mix of
versions meanwhile is fine.
- **The store** (PostgreSQL): each member is dialled for 3 s before the
next is tried. A store so far away that connecting takes longer needs
`connect_timeout=10` in its address.
- **Nodes** notice a control plane that died without closing its
connection in 8 s (was 15) once they run this release.
- **Judges installed by an earlier `server init`** keep reserving no cores
and 2 GB. To give them their own:
`llmgw ctl model resources qwen3guard-0.6b --memory 4 --cores 2`, then
`llmgw ctl restart --model qwen3guard-0.6b`.
- **Patroni** replaces a lost primary within 20 s on a new cluster. On an
existing one, run `server init --patroni ...` again from the machine with
the authority; it changes only that.
- **A floating address** keeps VRRP router id 51 until `server add --vip`
runs again, which moves it to one of its own; run it for every standby.
- A request whose judge is not there yet waits up to the policy's
`latency_budget` for one before `fail: closed` refuses it.
### Added
- **Setup lets every part be chosen, each with its settings, and says what each is.** `sudo llmgw` on a new machine lists
the parts of an install: the control plane, the store, the model mirror, this machine as a node, the starter model,
pictures and 3D, the guardrails' judge, the firewall, notifications, limits and keys, on-demand turns, an offline
bundle. Beside each is what it will be or what is missing without it. Space turns a part on or off, and Enter opens
its settings in a dialog that explains it at more length. The settings are: which starter model (six Qwen sizes, or
any GGUF on Hugging Face) and its context; the mirror's directory or another machine's mirror; the judge's
categories, copies, wait and what happens when it does not answer; the portal's language; the sign-up form; the
default limits; the longest a key lives; the on-demand waits. A plan page then lists what is installed and what is
left out, with the same install as a command. Every setting is a `server init` flag too (`--starter`,
`--judge-fail`, `--media`, `--max-key-ttl`, ...).
- **Three machines are high availability for the gateway, not only the store.** With three machines, setup makes the two
database machines control planes too, standing by while the first leads, with an optional floating address
(keepalived) that follows whichever leads. Any one machine can now be lost. By command: `server init --patroni ...
--standbys --vip 10.0.0.160/24`. The mirror's management API answers the three control planes, so a standby that
comes to lead can still fetch models.
- **Pictures uploaded to work from**: `/v1/files` takes `purpose=vision` or
`user_data` besides `batch`, kept as long as job results, so a picture is
sent once and named by its `file-…` id in the MCP tools and a workflow's
inputs.
- **One ComfyUI for an environment's models**: ComfyUI models in one
`comfyui.environment` (two or more) share one replica — the catalog
makes it a hidden entry, `comfyui-env-<name>`, on the first member's
profiles with the most memory any member needs, carrying every member's
weights and custom nodes. Asking for any member starts it; it answers for
each with that member's workflow and checkpoint, and the members get no
replicas of their own. A picture model and a 3D model on one card stop
handing it to each other with a cold start each way.
- **Pictures, 3D models, vision and workflows as MCP tools** at `/mcp`,
beside the memory notes, each offered only where the gateway has a model
for it: `generate_image` (with `concept`, `side` and `texture` styles),
`generate_mesh` (from a picture or a prompt), `review_images` (a vision
model, to a JSON schema), `run_workflow`, `job_status` and
`gateway_status`. Each makes its request under the caller's key, so
allowlists, limits, licences and budgets apply and it is charged like
any request; what it makes is kept as the caller's file and answered by
id and download URL. A client that takes a stream gets
`notifications/progress` while a mesh is made and waits up to ten
minutes; one that does not, 50 seconds, then the job id.
- **Setup asks where alerts go**: `llmgw server init --webhook URL`
(repeatable) and a **Notification webhooks** field on both setup screens.
Slack's, Mattermost's and Rocket.Chat's addresses are sent Slack's text,
any other the event as JSON (`slack:` or `json:` in front says which).
They are added to the cluster's `notify.webhooks` beside those already
there, and each is sent a test once the control plane runs on them.
- **Extended thinking on `/v1/messages`**: `thinking: {type: "enabled"}`
tells a model that thinks by its chat template to (`enable_thinking`),
and its reasoning comes back as `thinking` blocks before the text,
streamed as `thinking_delta` events; `disabled` tells it not to. Not
asked for, the reasoning is left out as before.
- **How long on-demand models hold a card** is a setting, in force on the
leader within seconds: `on_demand.evict_after_idle` (2m unless set) and
`on_demand.min_lifetime` (5m). On one card shared by a picture model and
a 3D model, 1m each brings the wait between a picture and its mesh to
about a minute.
### Fixed
- **The judges and the starter model divide a machine's CPUs instead of each taking all of them.** `server init` gave the
judge and the starter no cores of their own, so every llama.cpp engine ran on every core: two judges and the starter
on a 4-core control plane competed, and under load 38 of 60 requests were refused for want of a judgment. Moved to a
node of their own with 2 cores each, the same test refused 2. When this machine is the node, init now gives each judge
replica 1 core (2 from 8 cores) and the starter what is left (at most 8); judges bound for other nodes get 2 each. A
judge also reserves 4 GB, not 2: it uses about 3.2.
- **A lost store machine stops costing requests most of a minute.** The store's connections try each member for 3 seconds,
not until the kernel gives up, and a connection to a machine that is gone is closed within about 10 seconds (TCP
keepalive, and `TCP_USER_TIMEOUT` on Linux). Patroni replaces a lost primary within 20 seconds (`ttl` 20, `loop_wait`
5, `retry_timeout` 5). Running `server init --patroni` again applies this to an existing cluster. Measured on three
VMs with the primary's power cut: about 45 seconds of hanging requests before.
- **Leadership stays put while the store's primary is replaced.** Replacing a lost primary takes longer than the
leadership lease lives, so the lease had lapsed for everyone by the time the store answered again. Whoever asked first
took it, as often a standby as the leader. A standby that saw the store out of reach now leaves the lease alone for one
round after it returns, so the leader renews it, and a leader that is truly gone is replaced a round later. With that,
the default lease is 10 seconds (was 15). A node notices a control plane that died without closing its connection in
8 seconds (was 15), so it is on its way to the new leader as that one takes over.
- **A judge replica that hangs no longer costs the request.** All the tries of a judge request shared the policy's
budget, so a replica that hung used it all and the try on another replica had none left: under `fail: closed` the
request was refused while a judge stood idle. When the first replica has not answered by half the budget, the same
question now goes to a replica on another machine too, and the first answer is taken. The first replica is not cut
short, which on a slow machine would refuse answers it was about to give. Replicas on one machine whose CPUs are taken
are slow together, so asking both only doubles the work there: measured that way, as many requests failed as before.
On such a machine the remedy is room, not a second question: judges on more than one node, more cores, or the judge
on a GPU.
- **A new leader waits for the judge instead of refusing at once.** The moment a control plane takes the lead it knows no
judge yet. When every judge fails at once, they are asked again every 250 ms while the policy's `latency_budget` lasts,
so `fail: closed` no longer answers every request in those seconds with 503 (about 17 seconds of them in the test). A
judge that is slow is not asked again, and an alarm is raised once per request, not once per try.
- Node discovery for Prometheus listed only the control plane asked and the
store, so a machine that is both was a control plane to one and a store
member to the other — scraped twice — and a standby asked alone lost every
node. Every control plane lists all of them alike; deploy/monitoring's
prometheus.yml says to scrape and ask each.
- A model whose weights and context pool did not fit its card was added
and then failed to make its context on every start, taking the card from
others each time (Apertus 70B with a 65536-token pool, 8B with 131072, on
24 GB). `ctl model serve` shrinks the pool to what the card holds, saying
so, and refuses weights that do not fit at all; the portal's serve form
refuses such a pool and says the largest that fits.
- Removing weights from the mirror that a catalog model still loads is
refused, naming the model: removed from the mirror alone, the model stayed
in the catalog and its replicas went on loading the node's copy.
- Jobs ran one at a time, so a 3D job waiting for the card held up another
model's job that could have run. They run side by side, one per model at
a time and eight in all, on the leader as before.
- `llmgw ctl model serve` served an embedding model as a chat model: no
`--embedding`, four slots, a 512-token batch, `embeddings` unset and a
"say hello" smoke test it could not pass. A model whose GGUF says it
pools (or `--embeddings`) is served for embedding: 32 slots of 1024
tokens unless sized otherwise, a batch that holds a whole passage, and
one vector asked for as its smoke test.
- `llmgw ctl upgrade` checks every node's version once the waves are done
and fails naming any not on the release, and says when the control plane
has no nodes registered; `llmgw upgrade` on a control plane says how many
nodes it left behind and the command that upgrades them. A site's nodes
stayed a release behind with nothing to say so.
- The cluster and node pages swapped their live part every few seconds,
forms and all, so filling one in raced the clock: what was typed, an
open choice and the focus were lost. The refresh now waits while a field
in it has the focus or a form in it has been typed into and not sent;
the mirror page's reload during a download waits the same way.
## v0.14.5 — 2026-10-14
+12 -1
View File
@@ -40,7 +40,7 @@ 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.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) |
| 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
@@ -50,6 +50,17 @@ budgets and rate limits decide who may use any of it.
Since v0.11.0 (each release's detail is in [CHANGELOG.md](CHANGELOG.md)):
- **Setup that explains, and high availability that is whole** (v0.14.6).
`sudo llmgw` lists the parts of an install, each with what it is for and
what is missing without it, and every one can be turned off or set: the
starter model and its context, the mirror's directory, the judge's
categories and what it does when it does not answer, limits, the
portal's language, pictures and 3D. With three machines each runs the
control plane, with a floating address on the leader, and any one can
be lost: on three VMs, cutting the store primary's power lost no
request. One ComfyUI serves an environment's models, pictures, 3D and
workflows are MCP tools, and `/v1/messages` gives a model's reasoning as
thinking blocks.
- **Picture and 3D models that add themselves** (v0.14.5). Where a node's
card fits flux-schnell or triposr and the catalog makes nothing of that
kind, the leader adds it on demand, so a new cluster's portal has its
+37 -12
View File
@@ -1,4 +1,4 @@
# Two control planes
# More than one control plane
llmgw handles the hard half of this itself: only one control plane places work,
and agents find whichever one is leading. What it does not do is move client
@@ -37,7 +37,7 @@ nothing in front of the two had moved.
So something has to. The options, in the order most people should consider
them:
**A load balancer you already run.** Health-check `/ready` on each control
**A load balancer you already run.** Health-check `/leader` on each control
plane and send traffic to whichever answers. This is the right answer if you
have one, and it needs nothing else from this directory.
@@ -50,25 +50,50 @@ in whatever your resolvers actually honour rather than what you set, which on
a bad day is minutes. Fine for a cluster people use interactively, wrong for
one an agent is driving.
## Check `/ready`, not `/health`
## Check `/leader`, not `/health`
This is the one thing to get right, and the obvious choice is the wrong one.
`/health` says the process is up. It says nothing about whether it can serve,
`/health` says the process is up. It says nothing about whether it leads,
and a standby is exactly that case: alive, answering, and holding no endpoints
at all — its reconciler placed nothing and it has no agent streams to adopt
from. A load balancer checking `/health` would send it half the traffic, and
half the traffic would be a 503.
from. A standby passes client requests on to the leader, so traffic sent to
one still works — until the leader is the machine that was lost, and there
is nobody to pass them to.
`/leader` is 200 on the control plane that leads and 503 on a standby. It is
what keepalived follows for the floating address, and what a load balancer
should send traffic by: to the one machine that answers 200.
`/ready` is narrower: 200 only when this process has somewhere to send a
request, so 503 on a standby and also on a leader with no model running yet.
Checking it takes the portal and the admin pages away with the models — on a
new cluster with no node yet, nobody can sign in to add one. Use it to stop
sending inference only, if your load balancer can tell the paths apart.
`/ready` says this process has somewhere to send a request. It is 503 on a
standby, 503 on a leader whose replicas have all failed, and 200 when there is
something to serve — which is the question a load balancer is actually asking.
Both are open, because a load balancer has no key.
During a failover neither passes for about the length of the lease handover:
During a failover nothing passes for about the length of the lease handover:
three seconds when the leader is stopped cleanly, up to the lease TTL when it
is not. That gap is real and there is no way to have it both ways — traffic
cannot go to a control plane that has nothing to route to.
is not. A proxy that waits that long for a healthy machine rather than
failing at once (Caddy's `lb_try_duration`) hides it from clients.
**Caddy**, in front of three control planes — the end of
[deploy/proxy/Caddyfile](../proxy/Caddyfile) has it with the rest of the
settings:
```
reverse_proxy 10.0.0.157:8080 10.0.0.158:8080 10.0.0.159:8080 {
lb_policy first
health_uri /leader
health_interval 2s
lb_try_duration 15s
flush_interval -1
}
```
With a floating address the address already follows the leader: give Caddy
that one address instead.
Not the admin port, for a different reason: it serves pprof, and pprof can
dump the process heap, where API keys sit in plaintext.
+3 -1
View File
@@ -41,7 +41,9 @@ vrrp_instance llmgw {
# MASTER on the machine you would rather serve; BACKUP on the other.
state MASTER
interface eth0
# Any number both machines agree on and nothing else on the segment uses.
# Any number both machines agree on and nothing else on the segment uses:
# another cluster's keepalived with the same one reads as an invalid
# password. `llmgw server add --vip` derives one from the address.
virtual_router_id 51
# The higher one wins while both are healthy. The weight above is enough
# to invert this when the preferred machine's gateway is not answering.
+13 -1
View File
@@ -21,11 +21,18 @@ scrape_configs:
# The control plane's dedicated metrics port. Not its admin listener: that one
# is loopback because it also serves pprof, and pprof can dump the heap where
# API keys sit in plaintext.
#
# Every control plane, one line each: the leader moves on restarts and the
# router's metrics are the leading one's, so scraping one alone leaves gaps
# — and alerts on them — whenever another leads.
- job_name: llmgw
static_configs:
- targets: ['10.0.0.140:9091']
labels:
instance: control-plane
instance: control-plane-140
# - targets: ['10.0.0.150:9091']
# labels:
# instance: control-plane-150
# Every machine of the cluster, for its node exporter: network and disk
# traffic, filesystems, CPU time. The control plane lists them — nodes,
@@ -33,10 +40,15 @@ scrape_configs:
# nothing edited here, and each carries its llmgw id as the node label.
# `llmgw node add` installs the exporter; on a machine added before that,
# apt-get install prometheus-node-exporter.
# Asked of every control plane too: a standby lists only the control
# planes and the store, and they label every machine alike, so the same
# target from two of them is scraped once.
- job_name: nodes
http_sd_configs:
- url: http://10.0.0.140:9091/prometheus/targets
refresh_interval: 1m
# - url: http://10.0.0.150:9091/prometheus/targets
# refresh_interval: 1m
- job_name: prometheus
static_configs:
+34
View File
@@ -14,6 +14,7 @@ llm.example.com {
max_size 32MB
}
# One control plane. With more than one, see the end of this file.
reverse_proxy 127.0.0.1:8080 {
# Tokens are written the moment they are generated. Without this a
# streamed reply can arrive in bursts, and a client measuring time to
@@ -39,3 +40,36 @@ llm.example.com {
# Access log to stderr, which under systemd is the journal.
log
}
# More than one control plane (server init --standbys, server add): one of
# them leads, the others stand by. Name them all and send to the one that
# leads — /leader is 200 there and 503 on a standby. Not /ready: that is 503
# on a leader with no model running yet, and would take the portal away with
# it. A standby would pass a request on to the leader anyway, but not while
# there is none, and the hop is for nothing.
#
# With a floating address (--vip) the address already follows the leader:
# give reverse_proxy that one address instead, and keep the rest as above.
#
# llm.example.com {
# request_body {
# max_size 32MB
# }
# reverse_proxy 10.0.0.157:8080 10.0.0.158:8080 10.0.0.159:8080 {
# # The first that is healthy: the leader, whichever it is.
# lb_policy first
# health_uri /leader
# health_interval 2s
# health_timeout 2s
# # While leadership moves — a few seconds when a leader is lost —
# # none is healthy; a request waits that long for one rather than
# # failing at once.
# lb_try_duration 15s
# lb_try_interval 250ms
# flush_interval -1
# transport http {
# response_header_timeout 15m
# }
# }
# log
# }
+53
View File
@@ -9292,6 +9292,10 @@
"origin": {
"$ref": "#/components/schemas/catalog.Origin",
"description": "Origin is the registry repository the weights came from, at which commit, and each file's checksum there. Written when a model is served from the registry; for one that was not, worked out from the mirror's record of where it fetched the file. It is what lets the control plane say that the repository has published different weights since, and move the model to them."
},
"shared": {
"$ref": "#/components/schemas/catalog.SharedComfy",
"description": "Shared is set on an environment's hidden entry only: the members its replica answers for. Made by the catalog, never written."
}
}
},
@@ -9545,6 +9549,51 @@
}
}
},
"catalog.SharedComfy": {
"type": "object",
"description": "SharedComfy is what an environment's entry carries of its members.",
"properties": {
"environment": {
"type": "string"
},
"members": {
"type": "array",
"items": {
"$ref": "#/components/schemas/catalog.SharedMember"
}
}
}
},
"catalog.SharedMember": {
"type": "object",
"description": "SharedMember is one model a shared replica answers for.",
"properties": {
"name": {
"type": "string"
},
"aliases": {
"type": "array",
"items": {
"type": "string"
}
},
"source": {
"type": "string",
"description": "Source is the member's weights, which the replica's node fetches."
},
"hf_repo": {
"type": "string",
"description": "HFRepo is the repository its node loads the weights by name from."
},
"workflow": {
"type": "string",
"description": "Workflow and Checkpoint are what a request for it is sent."
},
"checkpoint": {
"type": "string"
}
}
},
"eval.Live": {
"type": "object",
"description": "Live is a run as it stands: one running, with how many of its cases are done, or one finished.",
@@ -10413,6 +10462,10 @@
"type": "boolean",
"description": "Tools says the model makes tool calls the engine returns as calls: it is written into the catalog, where clients and editors read it. Check a running replica with llmgw ctl tool-check before saying so."
},
"embeddings": {
"type": "boolean",
"description": "Embeddings serves it as an embedding model, which its GGUF header says by itself for most: --embedding, many slots, a batch that holds a whole passage, /v1/embeddings."
},
"cpu_cores": {
"type": "integer",
"description": "CPUCores is what a replica computes on; zero takes half the smallest node it fits on."
+1 -1
View File
@@ -82,7 +82,7 @@
"/mcp": {
"post": {
"operationId": "postMcp",
"summary": "The gateway's MCP server: the memory notes as tools",
"summary": "The gateway's MCP server: the memory notes, and pictures, 3D models, vision and workflows, as tools",
"tags": [
"mcp"
],
+45
View File
@@ -534,6 +534,51 @@ curl https://llm.example.com/mcp \
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
```
**Pictures, 3D models, vision and workflows.** Where the gateway has the
models for them, the same server offers more tools — only those it has a
model for:
| Tool | Does |
|---|---|
| `generate_image` | A picture from a prompt. `style` adds what a picture for a mesh wants: `concept` (one object, three-quarter view, grey background), `side` (an exact side view), `texture` (a seamless tile) or `none`. Answers the PNG's `file_id`, `url` and `seed`, with a 512 px preview to look at. |
| `generate_mesh` | A GLB from a picture — `image` is a `file-…` id (a `generate_image` result, say), a data URL or base64 — or from a `prompt`, for which a concept picture is made first. `seed`, `resolution`, `texture_resolution`, and any other input the model's workflow declares in `inputs`. Answers the GLB's `file_id`, `url` and licence. |
| `review_images` | Asks a vision model about one to four pictures (each shrunk to 1280 px on its long side); `json_schema` makes it answer that JSON. |
| `run_workflow` | Runs a ComfyUI workflow from the inputs its catalog entry declares, and answers its result file. |
| `job_status` | Where a mesh or a workflow run stands, and its file when done; `wait_seconds` waits for it. |
| `gateway_status` | The picture, 3D, vision and workflow models, whether each is loaded on a GPU now, a workflow's inputs, and your last jobs. |
Each tool makes its request of the gateway under your key, so your
allowlist, limits, left-out licences and budget apply, and it is charged
and listed like any other request. What it makes is kept as your file for
as long as job results are: fetch it from `url` with the same key (`curl -H
"Authorization: Bearer $LLMGW_API_KEY" -o rock.glb <url>`). A tool server
over HTTP cannot write to your disk, so an agent that needs the file
locally — to import it in Blender — fetches it, or goes through a small
local bridge that does.
A picture can be uploaded once and named by its id after: `POST /v1/files`
with `purpose=vision` (or `user_data`) keeps it, for as long as job results
are kept, as a file of yours that `generate_mesh`, `review_images` and a
workflow's file inputs take as `file-…`. A call is at most 64 MB.
A model that is not loaded is waited for: a picture or a look at one whose
model is starting, or waiting for another to give up the GPU, waits as a
mesh does, saying so, rather than failing with "model loading".
A mesh takes from half a minute to a few minutes. A client that takes the
answer as a stream (`Accept: text/event-stream`, as Claude Code does) is
answered that way: `notifications/progress` for the call's
`progressToken` while it runs — queued, waiting for the GPU, the node and
its percent — then the result, and the call waits up to ten minutes. One
answered in one piece waits 50 seconds; a job not done by then is answered
with its `job_id`, to ask after with `job_status`. `wait_seconds` (up to
900) sets either.
```
claude mcp add --transport http llmgw https://llm.example.com/mcp \
--header "Authorization: Bearer $LLMGW_API_KEY"
```
### Licences you leave out
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
+90 -10
View File
@@ -89,13 +89,36 @@ CGO_ENABLED=0 go build -o llmgw ./cmd/llmgw
**The short way is the screens.** On the machine that will be the control
plane, run `sudo llmgw` with nothing after it. With nothing installed it asks
whether this is one machine or three for high availability; for three, it asks
for the other two — address, user, and an ssh key, the ssh agent, or a
password, and the sudo password if sudo asks for one; or, for a machine
nothing may log into, a join code to run there with `sudo llmgw join <code>`,
which dials out to this one on port 7444 — checks all three before
installing anything (system, room, clock, ports, internet, whether they reach
one another), shows the plan, and installs. Passwords are held in memory for
whether this is one machine or three for high availability, then lists what
an install is made of, each part with what it is for and what is missing
without it:
| Part | | Its settings (Enter) |
|---|---|---|
| Control plane | always | the administrator, other names for the certificate, the portal's language, the sign-up form |
| Store | always | SQLite or PostgreSQL on this machine; with three machines, PostgreSQL under Patroni on the other two |
| Control plane on all three | three machines, on | a floating address for whichever leads |
| Model mirror | on | here, and in which directory, or on another machine |
| This machine as a node | on for one machine | |
| Starter model | on for one machine | which: Qwen2.5 0.5B, 1.5B or 3B, Qwen3 4B, 8B or 14B, or any GGUF on Hugging Face; its context |
| Pictures and 3D | off | |
| Guardrails' judge | on | refuse or serve flagged when it does not answer; copies; how long it is waited for; what it refuses |
| Firewall | on | |
| Notifications | off | the webhooks |
| Limits and keys | always | requests and tokens a minute; the longest a key lives |
| Taking turns on a card | always | how long an on-demand model goes unused, and is safe after loading |
| Offline bundle | off | the bundle and its sha256 |
Space turns a part on or off; Enter opens its settings, which say more. What
cannot go together is said before anything is done — a starter model with no
mirror to fetch it into — and closing the message opens the setting that is
wrong. For three machines it then asks for the other two — address, user, and
an ssh key, the ssh agent, or a password, and the sudo password if sudo asks
for one; or, for a machine nothing may log into, a join code to run there
with `sudo llmgw join <code>`, which dials out to this one on port 7444 —
checks all three before installing anything (system, room, clock, ports,
internet, whether they reach one another). Last it shows the plan, part by
part, what is left out and what that costs, and installs. Passwords are held in memory for
the run only; a sudo password lets the user sudo without one until the install
finishes, and is then taken back. At the end it shows the administrator's
password and the command that does the same from a script.
@@ -137,7 +160,16 @@ That is the whole step. In order, it:
machine without room for it is told so within two minutes, and why.
`--no-mirror` and `--no-node` leave out 7 and 8, `--no-firewall` 9,
`--no-guard-model` 10 and `--no-model` 11. An NVIDIA card with no working
`--no-guard-model` 10 and `--no-model` 11. Every setting the screens ask for
is a flag too, and the plan shows the command with them: `--starter qwen3-8b`
(or `hf:<owner>/<repo>/<file>.gguf`) and `--starter-context`, `--mirror-dir`,
`--judge-fail open`, `--judge-categories`, `--judge-replicas`,
`--judge-budget`, `--language tr`, `--signup-open`, `--default-rpm`,
`--default-tpm`, `--max-key-ttl`, `--media` (pictures and 3D: ComfyUI on the
nodes, and `presets.auto`), `--evict-after-idle` and `--min-lifetime`. They
go into `server.yaml` as init writes it, and become the cluster's first
settings when it starts; a `server.yaml` already there is never overwritten,
so run again they change nothing — the settings page does. An NVIDIA card with no working
driver is reported rather than silently ignored; install the driver and run
init again to serve on it.
@@ -1827,6 +1859,23 @@ same event about the same thing is sent at most once an hour, so a budget that
refuses a thousand requests is one message. `llmgw ctl notify test` sends a
test event to each webhook and says how each answered.
Setup asks for them: **Notification webhooks** on its first page, or
`--webhook` to `llmgw server init`, as often as there are webhooks:
```
sudo llmgw server init --admin ops@example.com \
--webhook https://hooks.slack.com/services/… \
--webhook json:https://ops.example.com/llmgw
```
An address of Slack's, or one with `/hooks/` in it (Mattermost, Rocket.Chat)
or ending in `/slack`, is sent Slack's `{"text": …}`; any other the event as
JSON; `slack:` or `json:` in front says which. They are added to the
cluster's settings beside any already there — run again, nothing is added
twice — and once the control plane runs on them, each is sent a test, whose
answer setup prints. Events to choose and a signing secret are set in the
settings afterwards (`llmgw ctl config edit`).
### Tracing
Metrics say that a model took four minutes to start; a trace says where they
@@ -2302,6 +2351,33 @@ Without authentication, `threads.store` and `threads.retain_hours` in
`server.yaml` keep both conversations and notes in one shared pool. That is for
one trusted machine only; see [development.md](development.md).
## Three machines, each a control plane
The screens' three machines are high availability for the gateway as well as
the store: the two database machines are made control planes too, standing
by while the first leads — what `server add`, below, does for one machine.
By command, `--standbys` with `--patroni`, and `--vip` for the floating
address:
```
sudo --preserve-env=SSH_AUTH_SOCK llmgw server init --admin you@example.com \
--patroni ubuntu@10.0.0.6 --patroni ubuntu@10.0.0.7 \
--standbys --vip 10.0.0.160/24 --no-node --no-model
```
| Machine | Runs |
|---|---|
| the first | control plane, leading at first; model mirror; etcd |
| the other two | control plane, standing by; PostgreSQL under Patroni; etcd |
etcd votes on all three and PostgreSQL is on two, so any one machine can be
lost: a standby leads within seconds, Patroni promotes the standby database
if it was the primary, and the floating address follows whichever leads. The
mirror's management API answers the three control planes' addresses, not
only its own machine's, so a standby that comes to lead can still fetch and
add models. The mirror itself stays on the first machine: while it is down,
models the nodes already have keep serving, and new ones wait for it.
## Two control planes
Once the store is replicated (`server init --patroni`, above), one command on
@@ -2318,8 +2394,12 @@ and lets it into the store; it starts there as a standby. Every node is told
of it within half a minute and fails over to it without being touched.
`--vip` keeps a floating address on whichever control plane leads, with
keepalived on both: that is the address to give clients, or a proxy in front.
Without it, put a load balancer in front that checks `/ready` — not
`/health`, which is 200 on a standby that would refuse every request.
Without it, put a proxy in front that sends to whichever answers `/leader`
with 200 — not `/health`, which is 200 on a standby too, and not `/ready`,
which is 503 on a leader with no model yet and would take the portal away
with it. The end of [deploy/proxy/Caddyfile](../deploy/proxy/Caddyfile) has
Caddy doing that for three control planes, and
[deploy/ha/](../deploy/ha/README.md#check-leader-not-health) why.
- **What a failover costs.** Measured on the reference cluster, stopping the
leader: the standby led within five seconds, every node had moved to it
+32 -1
View File
@@ -205,6 +205,8 @@ rather than repeated.
| `no_passwords` | `bool` | [OIDC](#oidc) |
| `mirror` | `string` | [Offline](#offline) |
| `checksums` | `string` | [Offline](#offline) |
| `evict_after_idle` | `time.Duration` | [OnDemandSettings](#ondemandsettings) |
| `min_lifetime` | `time.Duration` | [OnDemandSettings](#ondemandsettings) |
| `nvidia_smi` | `string` | [Options](#options) |
| `ib_root` | `string` | [Options](#options) |
| `prefer_nvml` | `bool` | [Options](#options) |
@@ -346,6 +348,7 @@ rather than repeated.
| `serving` | `ServingSettings` | [Server](#server) |
| `firewall` | `FirewallSettings` | [Server](#server) |
| `presets` | `PresetSettings` | [Server](#server) |
| `on_demand` | `OnDemandSettings` | [Server](#server) |
| `agents` | `AgentSettings` | [Server](#server) |
| `firewall_networks` | `[]string` | [Server](#server) |
| `backup_recipients` | `[]string` | [Server](#server) |
@@ -945,7 +948,7 @@ work stands without being renewed.
Only meaningful with a shared store, where a second control plane can
see the claim. Long enough to survive a slow query or a brief network
stall, short enough that a standby takes over before anyone has finished
reading the alert. Zero takes the default.
reading the alert. Zero takes the default, 10s.
## CustomNode
@@ -1912,6 +1915,25 @@ kept by llmgw bundle import and llmgw bundle trust; every engine file
a node fetches from the mirror is checked against it. Read again
whenever it changes. Empty is /etc/llmgw/offline.sha256.
## OnDemandSettings
### `evict_after_idle`
`time.Duration`
EvictAfterIdle is how long an on-demand replica must go unasked-for
before a model that wants its card may have it. Zero keeps 2m.
### `min_lifetime`
`time.Duration`
MinLifetime is how long a replica that has just come up is safe from
that, so two models do not take turns paying for loads. Zero keeps
5m. On one card shared by a picture model and a 3D model, 1m each
brings the wait between a picture and its mesh to about a minute.
Both are in force on the leader within seconds.
## Options
### `nvidia_smi`
@@ -2984,6 +3006,15 @@ Presets is how the models that come with llmgw reach the catalog.
Takes a [PresetSettings](#presetsettings) block.
### `on_demand`
`OnDemandSettings`
OnDemand is how models that load when asked for take turns on the
cards.
Takes a [OnDemandSettings](#ondemandsettings) block.
### `agents`
`AgentSettings`
+1 -1
View File
@@ -35,6 +35,7 @@ require (
golang.org/x/net v0.59.0
golang.org/x/oauth2 v0.37.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/text v0.42.0
google.golang.org/protobuf v1.36.12
@@ -88,7 +89,6 @@ require (
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
go.opentelemetry.io/otel/metric v1.46.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
golang.org/x/sys v0.48.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
google.golang.org/grpc v1.83.1 // indirect
+33 -4
View File
@@ -6,6 +6,7 @@ import (
"crypto/sha256"
"crypto/tls"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
@@ -1131,6 +1132,31 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
stop()
spec.Profile.ModelPath = path
}
// A shared ComfyUI replica's members' weights, each fetched like the
// model's, and handed to the gate where they landed.
if raw := spec.Profile.Env[engines.SharedModelsEnv]; raw != "" {
var ms []map[string]string
if err := json.Unmarshal([]byte(raw), &ms); err != nil {
report(supervisor.Failed, "the shared replica's models are not readable: "+err.Error(), nil)
return
}
for i, m := range ms {
stop := a.watchFetch(m["source"], func(p weights.Progress) {
send(&llmgwv1.ShardStatus{State: agentsvc.StateToWire(supervisor.Starting),
Fetch: &llmgwv1.FetchProgress{Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal}})
})
path, err := a.weights().Ensure(ctx, m["source"])
stop()
if err != nil {
report(supervisor.Failed, fmt.Sprintf("model %s: %v", m["name"], err), nil)
return
}
ms[i]["path"] = path
}
b, _ := json.Marshal(ms)
spec.Profile.Env = maps.Clone(spec.Profile.Env)
spec.Profile.Env[engines.SharedModelsEnv] = string(b)
}
// Adapters are fetched like the model, and before the engine starts: it
// loads them at start, and one missing is a replica that answers for the
// base model under the adapter's name.
@@ -1391,11 +1417,14 @@ func Client(cfg tlsconf.Config, timeout time.Duration) (*http.Client, error) {
// off — is never closed: nothing sends the FIN, and the agent's heartbeats sit
// in the socket's buffer until the kernel gives up retransmitting, fifteen
// minutes later. Found by switching off a leader: the standby took over in 14
// seconds and the node stayed with the dead one. An HTTP/2 ping after ten
// quiet seconds, given five to answer, finds it in a quarter of a minute.
// seconds and the node stayed with the dead one. An HTTP/2 ping after five
// quiet seconds, given three to answer, finds it in eight: by the time the
// standby leads, as it does within the lease's ten seconds, the node is on
// its way to it. At fifteen, with the lease at fifteen too, the new leader
// went without its nodes, and so its judges, for half a minute.
const (
streamIdle = 10 * time.Second
streamPing = 5 * time.Second
streamIdle = 5 * time.Second
streamPing = 3 * time.Second
)
// localizeSpec fills in what only this node knows.
+6 -1
View File
@@ -129,7 +129,7 @@ func (c *ComfyUI) Commands(s ShardSpec) ([]Command, error) {
// so taken before baseEnv drops them. Without the repository the gate
// links no Hugging Face cache, and sf3d's node went to the network for
// its gated repository and was refused with a 401.
for _, k := range []string{comfyNodesEnv, comfyHFRepoEnv} {
for _, k := range []string{comfyNodesEnv, comfyHFRepoEnv, SharedModelsEnv} {
if v := p.Env[k]; v != "" {
env[k] = v
}
@@ -144,3 +144,8 @@ const comfyNodesEnv = "LLMGW_COMFYUI_NODES"
// comfyHFRepoEnv is the repository a custom node loads the weights by name
// from; catalog.HFRepoEnv and comfy.HFRepoEnv by the same name.
const comfyHFRepoEnv = "LLMGW_COMFYUI_HF_REPO"
// SharedModelsEnv is a shared replica's members — name, source, and the
// path the agent fetched it to — as JSON; catalog.SharedModelsEnv and
// comfy.SharedModelsEnv by the same name.
const SharedModelsEnv = "LLMGW_COMFYUI_MODELS"
+2
View File
@@ -245,6 +245,8 @@ var liveSettings = []string{
"agents",
// Read by the leader's presets pass each time it runs.
"presets",
// Handed to the reconciler as they change.
"on_demand",
// Read by server init, node add and backups, not by the server.
"backup_recipients", "backup_copy_to",
}
+20
View File
@@ -268,6 +268,26 @@ func upgradeNodes(ctx context.Context, out io.Writer, client func() *ctlClient,
}
fmt.Fprintf(out, "%d node(s) upgraded to %s in %s, %d already on it\n",
done, v, time.Since(start).Round(time.Second), skipped)
// Checked at the end, against what the control plane says now: a node
// that is not on the version is said by name, and the command fails. A
// site's nodes stayed a release behind with nothing printed to say so.
if len(nodes) == 0 {
fmt.Fprintln(out, "This control plane has no nodes registered, so none were upgraded.")
}
var now []adminapi.NodeView
if err := client().get(ctx, "/api/v1/nodes", &now); err == nil {
var behind []string
for _, n := range now {
if (len(only) == 0 || slices.Contains(only, n.ID)) && n.AgentVersion != v {
behind = append(behind, fmt.Sprintf("%s (%s, %s)", n.ID, orUnknown(n.AgentVersion), n.State))
}
}
if len(behind) > 0 {
slices.Sort(behind)
return fmt.Errorf("not on %s: %s\nrun this again to try them, or `llmgw ctl logs NODE` for why",
v, strings.Join(behind, ", "))
}
}
// The one machine this does not reach. Said every time, because it is
// exactly the kind of thing that stays three versions behind unnoticed.
fmt.Fprintf(out, "The model mirror's host is not upgraded by this; on it, run:\n"+
+51
View File
@@ -150,3 +150,54 @@ func TestABinaryInstalledByHandIsNotAServerRunningIt(t *testing.T) {
}
}
}
func TestAnUpgradeThatFindsNoNodesSaysSo(t *testing.T) {
// A site's nodes stayed a release behind with nothing printed to say
// that none had been reached.
f := &fakeFleet{versions: map[string]string{}}
srv := f.server(t)
var out bytes.Buffer
if err := upgradeNodes(t.Context(), &out, func() *ctlClient { return newCtlClient(srv.URL) }, "v1.0.1", nil, time.Second, 1); err != nil {
t.Fatal(err)
}
if !strings.Contains(out.String(), "no nodes registered") {
t.Errorf("output:\n%s", out.String())
}
}
func TestAnUpgradeFailsNamingANodeLeftBehind(t *testing.T) {
// A node that joins, or comes back on its old binary, while the waves
// run is found at the end rather than passed over.
f := &fakeFleet{versions: map[string]string{"a": "v1.0.0"}}
srv := f.server(t)
late := func() *ctlClient {
f.mu.Lock()
if len(f.told) == 1 {
f.versions["late"] = "v1.0.0"
}
f.mu.Unlock()
return newCtlClient(srv.URL)
}
var out bytes.Buffer
err := upgradeNodes(t.Context(), &out, late, "v1.0.1", nil, 5*time.Second, 1)
if err == nil || !strings.Contains(err.Error(), "late (v1.0.0, ready)") {
t.Errorf("err %v", err)
}
}
func TestUpgradingAControlPlaneAloneSaysItsNodesWereNot(t *testing.T) {
f := &fakeFleet{versions: map[string]string{"p-g1": "v1.0.0", "p-g3": "v1.0.1"}}
srv := f.server(t)
var out bytes.Buffer
nodesLeftBehind(t.Context(), &out, newCtlClient(srv.URL), "v1.0.1")
if !strings.Contains(out.String(), "1 of 2 node(s) are not on v1.0.1") ||
!strings.Contains(out.String(), "ctl upgrade v1.0.1 --skip-control-plane") {
t.Errorf("output:\n%s", out.String())
}
out.Reset()
f.versions["p-g1"] = "v1.0.1"
nodesLeftBehind(t.Context(), &out, newCtlClient(srv.URL), "v1.0.1")
if out.Len() != 0 {
t.Errorf("said something with every node on the version: %s", out.String())
}
}
+2
View File
@@ -60,6 +60,8 @@ func newModelServeCmd(client func() *ctlClient) *cobra.Command {
f.StringVar(&req.Quant, "quant", "", "the quantisation to serve (default: the one that suits the cluster best)")
f.StringVar(&req.File, "file", "", "the file to serve, instead of choosing by --quant")
f.StringVar(&req.Projector, "projector", "", "a vision model's mmproj file, fetched and loaded beside it")
f.BoolVar(&req.Embeddings, "embeddings", false, "serve it as an embedding model (its GGUF header says so for most): "+
"--embedding, 32 slots of 1024 tokens unless --ctx-pool or --context-length says otherwise")
f.StringVar(&req.Format, "format", "gguf", "gguf, run by llama.cpp, or safetensors: the original weights, run by vLLM on GPUs")
f.BoolVar(&req.CPU, "cpu", false, "run it on the CPU, whatever the plan says")
f.StringVar(&req.GPUClass, "gpu-class", "", "run it on GPUs of this class, whatever the plan says")
+28
View File
@@ -1,8 +1,11 @@
package cli
import (
"context"
"errors"
"fmt"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"io"
"os"
"os/exec"
"runtime"
@@ -70,6 +73,9 @@ func newSelfUpgradeCmd() *cobra.Command {
if strings.TrimSpace(string(b)) == "active" {
if steady++; steady >= 3 {
fmt.Fprintf(out, "%s restarted and running\n", service)
if service == "llmgw-server" {
nodesLeftBehind(cmd.Context(), out, newCtlClient("127.0.0.1:9090"), v)
}
return nil
}
} else {
@@ -85,3 +91,25 @@ func newSelfUpgradeCmd() *cobra.Command {
c.Flags().StringVar(&service, "service", "", "the systemd unit to restart afterwards, e.g. llmgw-mirror")
return c
}
// nodesLeftBehind says, after a control plane alone was upgraded, that its
// nodes were not, and how they are: this command looks like the cluster's
// upgrade and is not, and a site's nodes stayed a release behind after it.
func nodesLeftBehind(ctx context.Context, out io.Writer, c *ctlClient, v string) {
var nodes []adminapi.NodeView
_ = c.patient(20*time.Second).get(ctx, "/api/v1/nodes", &nodes)
behind := 0
for _, n := range nodes {
if n.AgentVersion != v {
behind++
}
}
switch {
case len(nodes) == 0:
fmt.Fprintf(out, "This upgraded this machine only. The nodes are upgraded from the leading "+
"control plane with:\n sudo llmgw ctl upgrade %s --skip-control-plane\n", v)
case behind > 0:
fmt.Fprintf(out, "This upgraded this machine only: %d of %d node(s) are not on %s. On the leading "+
"control plane, run:\n sudo llmgw ctl upgrade %s --skip-control-plane\n", behind, len(nodes), v, v)
}
}
+4
View File
@@ -264,6 +264,7 @@ func newServerCmd() *cobra.Command {
agentSvc = svc
// Stopped by the operators (llmgw stop): nothing placed.
ctl.Reconciler().SetStopped(cfg.Serving.Stopped)
ctl.Reconciler().SetEvictionTimes(cfg.OnDemand.EvictAfterIdle, cfg.OnDemand.MinLifetime)
// Whether nodes keep llmgw's firewall, as the settings say now.
svc.SetFirewall(func() *llmgwv1.Firewall {
c := current.Load()
@@ -778,6 +779,9 @@ func newServerCmd() *cobra.Command {
fwPub.Kick()
}
}
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "on_demand") }) && admin != nil {
admin.Reconciler().SetEvictionTimes(next.OnDemand.EvictAfterIdle, next.OnDemand.MinLifetime)
}
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "agents") }) && agentSvc != nil {
// A moment apart per node, so off this goroutine.
go agentSvc.BroadcastAgentSettings()
+37 -5
View File
@@ -9,6 +9,7 @@ import (
"encoding/pem"
"errors"
"fmt"
"hash/fnv"
"io"
"net"
"os"
@@ -44,6 +45,11 @@ type serverAdd struct {
// addr is the machine's address as given; ip what the others reach it at.
addr, ip, name string
out io.Writer
// priority is its keepalived priority, under this machine's 110; 0 is
// 100. Two standbys get two, so which takes the address is not a tie.
priority int
// quiet leaves out the closing sentence, for init, which says its own.
quiet bool
}
func newServerAddCmd() *cobra.Command {
@@ -148,7 +154,9 @@ func (sa *serverAdd) run(ctx context.Context) error {
return fmt.Errorf("%s: %w", s.name, err)
}
}
fmt.Fprintf(sa.out, "\n%s is a control plane of this cluster, standing by while this one leads.\n", sa.name)
if !sa.quiet {
fmt.Fprintf(sa.out, "\n%s is a control plane of this cluster, standing by while this one leads.\n", sa.name)
}
return nil
}
@@ -511,18 +519,31 @@ func (sa *serverAdd) floating(ctx context.Context) error {
return err
}
here := pgha.Local{}
priority := sa.priority
if priority <= 0 {
priority = 100
}
for _, m := range []struct {
r onboard.Remote
name string
priority int
}{{here, "this machine", 110}, {sa.remote, sa.name, 100}} {
}{{here, "this machine", 110}, {sa.remote, sa.name, priority}} {
// The interface on the address's network, by its route: not the
// route to the address, which is lo on the machine holding it
// already, as a run after the first finds it.
out, err := m.r.Run(ctx, fmt.Sprintf(`set -e
command -v keepalived >/dev/null 2>&1 || { sudo DEBIAN_FRONTEND=noninteractive apt-get -qq update >/dev/null; sudo DEBIAN_FRONTEND=noninteractive apt-get -qq install -y keepalived >/dev/null; }
ip -o route get %s | awk '{for(i=1;i<=NF;i++) if($i=="dev") {print $(i+1); exit}}'`, vip), nil)
dev() { awk '{for(i=1;i<=NF;i++) if($i=="dev") {print $(i+1); exit}}'; }
d=$(ip -o -4 route show %s | dev)
[ -n "$d" ] && [ "$d" != lo ] || d=$(ip -o route get %s | dev)
echo "$d"`, nw.String(), vip), nil)
if err != nil {
return fmt.Errorf("on %s: %v: %s", m.name, err, strings.TrimSpace(string(out)))
}
iface := strings.TrimSpace(string(out))
if iface == "" || iface == "lo" {
return fmt.Errorf("on %s: no interface on %s's network", m.name, sa.vip)
}
conf := keepalivedConf(iface, vip.String(), prefix, m.priority, pass)
if out, err := m.r.Run(ctx, `set -e
sudo install -d -m 0755 /etc/keepalived
@@ -560,6 +581,17 @@ func vrrpPassword(etcDir string) (string, error) {
return pass, nil
}
// vrrpRouterID is the VRRP router id for a floating address: from the
// address, so two clusters on one network, each with its own address, do
// not answer each other's adverts — with one id for all, each logged the
// other's as an invalid password, and dropped its own address's
// announcements with them.
func vrrpRouterID(vip string) int {
h := fnv.New32a()
_, _ = h.Write([]byte(vip))
return int(h.Sum32()%255) + 1
}
func keepalivedConf(iface, vip string, prefix, priority int, pass string) string {
return fmt.Sprintf(`# llmgw: the gateway's floating address, on whichever control plane leads.
vrrp_script llmgw_leader {
@@ -572,7 +604,7 @@ vrrp_script llmgw_leader {
vrrp_instance llmgw {
state BACKUP
interface %s
virtual_router_id 51
virtual_router_id %d
priority %d
advert_int 1
authentication {
@@ -586,7 +618,7 @@ vrrp_instance llmgw {
llmgw_leader
}
}
`, iface, priority, pass, vip, prefix)
`, iface, vrrpRouterID(vip), priority, pass, vip, prefix)
}
// clientCapable says a control plane's certificate can also present itself
+116 -18
View File
@@ -73,6 +73,9 @@ type serverInit struct {
// patroni are user@host of the machines to keep the store on under
// Patroni, replicated; this machine is the third etcd vote for two.
patroni []string
// webhooks are where the cluster's alerts go: put into its settings
// once it runs, and each sent a test.
webhooks []string
// patroniNodes are the same machines already connected to, which the
// setup screen does with the password it was given; when set, patroni
// only names them.
@@ -92,9 +95,34 @@ type serverInit struct {
// bundle is an offline bundle to install from, and bundleSHA its
// manifest's sha256: the cluster is then set up offline.
bundle, bundleSHA string
// mirrorPath is the mirror's directory here; "" is /srv/models. A seam
// for tests.
// mirrorPath is the mirror's directory here; "" is /srv/models.
mirrorPath string
// What setup lets a person choose besides the parts: zero is the
// default (serverinit_choices.go).
starter starterSpec
starterCtx int
judge judgeSpec
// language is the portal's for a browser that asks for neither of its
// own; "" leaves it English.
language string
signupOpen bool
defaultRPM, defaultTPM int
maxKeyTTL time.Duration
// media has the nodes install ComfyUI and the leader add a picture model
// and a 3D model once a card fits one.
media bool
// evictAfterIdle and minLifetime are on_demand's; zero leaves its own.
evictAfterIdle, minLifetime time.Duration
// standbys makes each machine of the replicated store a control plane
// too, standing by while this one leads: three machines, each able to
// take over. vip is a floating address, as 10.0.0.160/24, kept on
// whichever leads.
standbys bool
vip string
// cpus is this machine's CPU count for dividing it between init's
// models; zero asks the machine. A seam for tests.
cpus int
// pgSetup prepares PostgreSQL and returns the store address; a seam for
// tests. Nil does it for real.
pgSetup func(context.Context) (string, error)
@@ -119,7 +147,10 @@ func newDefaultServerInit() serverInit {
func newServerInitCmd() *cobra.Command {
in := newDefaultServerInit()
var plain bool
var (
plain bool
starter, judgeCats string
)
cmd := &cobra.Command{
Use: "init",
Short: "Set this machine up as a control plane and start it",
@@ -155,6 +186,9 @@ func newServerInitCmd() *cobra.Command {
if os.Geteuid() != 0 {
return errors.New("init creates a user, writes /etc and starts a service: run it as root")
}
if err := in.readChoices(starter, judgeCats); err != nil {
return err
}
in.out = cmd.OutOrStdout()
if !plain && isTerminal(in.out) {
return in.runInitTUI(cmd.Context(), in.out)
@@ -182,9 +216,40 @@ func newServerInitCmd() *cobra.Command {
"leave the firewall alone, instead of keeping llmgw's internal ports to the cluster's networks")
f.BoolVar(&in.postgres, "postgres", false,
"keep the store in PostgreSQL on this machine, installing it and moving an existing SQLite store into it")
f.StringArrayVar(&in.webhooks, "webhook", nil,
"where alerts go — a model parked, a node lost, a budget spent: an http(s) address, Slack's, Mattermost's "+
"and Rocket.Chat's sent Slack's text, any other the event as JSON (slack: or json: in front says which); repeatable")
f.StringArrayVar(&in.patroni, "patroni", nil,
"user@host of a machine to keep the store on, replicated under Patroni; give two or more. "+
"Installs PostgreSQL, Patroni and etcd there over ssh, and moves the existing store in")
// What server.yaml is written with; each is in it only when init writes
// it, never into one already there.
f.StringVar(&in.mirrorPath, "mirror-dir", mirrorDir, "the directory the mirror on this machine keeps model files in")
f.StringVar(&starter, "starter", starterName,
"the starter model: "+strings.Join(starterNames(), ", ")+", or hf:<owner>/<repo>/<file>.gguf")
f.IntVar(&in.starterCtx, "starter-context", defaultContext, "the context, in tokens, a client may use of the starter model")
f.StringVar(&in.judge.Fail, "judge-fail", "closed",
"a request no judge read in time: closed refuses it, open serves it flagged")
f.StringVar(&judgeCats, "judge-categories", strings.Join(defaultJudgeCategories, ","),
"what the judge refuses, comma-separated, of: "+strings.Join(judgeCategories, ", "))
f.IntVar(&in.judge.Replicas, "judge-replicas", 2, "how many copies of the judge run, each on CPUs")
f.DurationVar(&in.judge.Budget, "judge-budget", 5*time.Second, "how long a request waits for the judge")
f.StringVar(&in.language, "language", "", "the portal's language for a browser that asks for neither: en or tr")
f.BoolVar(&in.signupOpen, "signup-open", false, "let anyone ask for an account on the portal, to be approved by an administrator")
f.IntVar(&in.defaultRPM, "default-rpm", defaultRPM, "requests a minute a key may make when nothing gives it a limit")
f.IntVar(&in.defaultTPM, "default-tpm", defaultTPM, "tokens a minute a key may use when nothing gives it a limit")
f.DurationVar(&in.maxKeyTTL, "max-key-ttl", defaultMaxKeyTTL, "the longest an API key lives")
f.BoolVar(&in.media, "media", false,
"pictures and 3D: the nodes install ComfyUI, and a picture model and a 3D model are added once a card fits one")
f.DurationVar(&in.evictAfterIdle, "evict-after-idle", 0,
"how long a model loaded on demand goes unasked-for before another may have its card (0: 2m)")
f.DurationVar(&in.minLifetime, "min-lifetime", 0,
"how long a model just loaded on demand is safe from that (0: 5m)")
f.BoolVar(&in.standbys, "standbys", false,
"with --patroni, make each of those machines a control plane too, standing by while this one leads")
f.StringVar(&in.vip, "vip", "",
"with --standbys, a floating address for whichever control plane leads, as 10.0.0.160/24 (keepalived on each)")
return cmd
}
@@ -249,6 +314,9 @@ func (in *serverInit) run(ctx context.Context) error {
fmt.Fprintf(in.out, " !! firewall: %v; llmgw's ports answer every network this machine is on\n", err)
}
}
if err := in.ensureWebhooks(ctx); err != nil {
fmt.Fprintf(in.out, " !! webhooks: %v\n", 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.
@@ -265,6 +333,11 @@ func (in *serverInit) run(ctx context.Context) error {
return fmt.Errorf("starter model: %w", err)
}
}
if in.standbys {
if err := in.ensureStandbys(ctx); err != nil {
return fmt.Errorf("standby control planes: %w", err)
}
}
in.nextSteps()
return nil
}
@@ -301,29 +374,45 @@ func (in *serverInit) configJudgesWith(model string) bool {
}
// guardSection is server.yaml's guard: the baseline every request passes,
// with the judge model when there is one.
// with the judge model, as init sets it up unless told otherwise, when there
// is one.
func guardSection(judge bool) string {
j := ` # A judge model reads what patterns cannot: violence, weapons, abuse,
if !judge {
return guardSectionFor(nil)
}
j := (&serverInit{}).judgeFor()
return guardSectionFor(&j)
}
// guardSectionFor is server.yaml's guard with this judge, or none.
func guardSectionFor(j *judgeSpec) string {
fail := "closed"
jb := ` # A judge model reads what patterns cannot: violence, weapons, abuse,
# self-harm. Installed by init; --no-guard-model leaves it out.
# judge: {model: ` + guardModelName + `, input: block, output: block, ...}
`
if judge {
j = ` # A judge model reads what patterns cannot: violence, crime, sexual
# content, self-harm, jailbreaks. Two replicas run on CPUs, about two
if j != nil {
fail = j.Fail
copies := map[int]string{1: "One replica runs", 2: "Two replicas run", 3: "Three replicas run"}[j.Replicas]
if copies == "" {
copies = fmt.Sprintf("%d replicas run", j.Replicas)
}
jb = ` # A judge model reads what patterns cannot: violence, crime, sexual
# content, self-harm, jailbreaks. ` + copies + ` on CPUs, about two
# seconds for a short text; an answer is read every 600 characters as
# it streams. With fail: closed, a request no judge can read within
# latency_budget is refused (503); open serves it, flagged, unjudged.
# PII is the patterns' (above), and copyright, unethical acts and
# politically sensitive topics are left out: they refuse too much
# ordinary work. Controversial counts as safe; controversial: unsafe
# makes it count. With a GPU: llmgw ctl guard judge enable --gpu, then
# llmgw ctl guard judge use ` + defaultGPUJudge + ` ` + guardModelName + `.
# politically sensitive topics are left out unless chosen: they refuse
# too much ordinary work. Controversial counts as safe; controversial:
# unsafe makes it count. With a GPU: llmgw ctl guard judge enable --gpu,
# then llmgw ctl guard judge use ` + defaultGPUJudge + ` ` + guardModelName + `.
judge:
models: [` + guardModelName + `]
input: block
output: block
categories: [Violent, Non-violent Illegal Acts, Sexual Content or Sexual Acts, Suicide & Self-Harm, Jailbreak]
latency_budget: 5s
categories: [` + strings.Join(j.Categories, ", ") + `]
latency_budget: ` + durationYAML(j.Budget) + `
`
}
return `# Guardrails (docs/guardrails.md). The baseline applies to every request;
@@ -333,13 +422,13 @@ guard:
default: [baseline]
policies:
baseline:
fail: closed
fail: ` + fail + `
input:
pii: {secret: block, card: redact}
injection: flag
output:
pii: {secret: block, card: redact}
` + j
` + jb
}
func (in *serverInit) done(format string, a ...any) {
@@ -518,6 +607,10 @@ func (in *serverInit) certHosts() []string {
for _, h := range in.hosts {
add(h)
}
// The floating address is this machine's whenever it leads.
if ip, _, err := net.ParseCIDR(in.vip); err == nil {
add(ip.String())
}
if name, err := os.Hostname(); err == nil {
add(name)
}
@@ -556,8 +649,13 @@ func (in *serverInit) ensureConfig(context.Context) error {
if contact == "" {
contact = "platform@example.com"
}
body := fmt.Sprintf(serverConfigTemplate, in.etcDir, in.etcDir, in.etcDir,
in.storeAddress(), contact, guardSection(in.judgeWanted()))
var judge *judgeSpec
if in.judgeWanted() {
j := in.judgeFor()
judge = &j
}
body := in.tuneConfig(fmt.Sprintf(serverConfigTemplate, in.etcDir, in.etcDir, in.etcDir,
in.storeAddress(), contact, guardSectionFor(judge)))
if err := os.WriteFile(path, []byte(body), 0o640); err != nil {
return err
}
+397
View File
@@ -0,0 +1,397 @@
package cli
import (
"fmt"
"math"
"net"
"path"
"slices"
"strconv"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
)
// What a first install lets a person choose beyond which parts it has: which
// starter model, how the judge decides, what the portal speaks, the limits a
// key starts with, and whether pictures and 3D come by themselves. Each zero
// value is what init did before it could be chosen, so `llmgw server init`
// with no flags installs what it always did.
//
// All of it is written into server.yaml when init writes it, and the cluster's
// keys among it become the cluster's first settings when the control plane
// first starts. A server.yaml already there is never overwritten, so a second
// run changes none of it: the settings page and `llmgw ctl config` do.
// starterSpec is a model init can fetch and serve so there is something to ask.
type starterSpec struct {
Name, Alias, Source string
// SizeGB is the weights' size, for the memory a node needs; 0 is not known.
SizeGB float64
About string
}
// starterChoices are the starters offered, smallest first: the first is the
// default. Each is a single GGUF file, Q4_K_M.
var starterChoices = []starterSpec{
{starterName, starterAlias, starterSource, 0.5, "answers at once on any CPU; fast, not clever"},
{"qwen2.5-1.5b-instruct", "qwen-1.5b", "hf:Qwen/Qwen2.5-1.5B-Instruct-GGUF/qwen2.5-1.5b-instruct-q4_k_m.gguf", 1.1,
"still quick on a CPU, and noticeably better"},
{"qwen2.5-3b-instruct", "qwen-3b", "hf:Qwen/Qwen2.5-3B-Instruct-GGUF/qwen2.5-3b-instruct-q4_k_m.gguf", 2.1,
"slow on a CPU, quick on any GPU"},
{"qwen3-4b", "", "hf:Qwen/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf", 2.5,
"thinks before it answers; a GPU is worth having"},
{"qwen3-8b", "", "hf:Qwen/Qwen3-8B-GGUF/Qwen3-8B-Q4_K_M.gguf", 5.0,
"a real assistant; a GPU with 8 GB or more"},
{"qwen3-14b", "", "hf:Qwen/Qwen3-14B-GGUF/Qwen3-14B-Q4_K_M.gguf", 9.0,
"better still; a GPU with 16 GB or more"},
}
// starterFrom reads --starter: a name from starterChoices, or the hf: source
// of any GGUF file, named after the file.
func starterFrom(s string) (starterSpec, error) {
s = strings.TrimSpace(s)
for _, c := range starterChoices {
if s == c.Name || (c.Alias != "" && s == c.Alias) {
return c, nil
}
}
if rest, ok := strings.CutPrefix(s, "hf:"); ok && strings.Count(rest, "/") >= 2 && strings.HasSuffix(strings.ToLower(rest), ".gguf") {
name := strings.ToLower(strings.TrimSuffix(path.Base(rest), path.Ext(rest)))
return starterSpec{Name: name, Source: s, About: "chosen at install"}, nil
}
var names []string
for _, c := range starterChoices {
names = append(names, c.Name)
}
return starterSpec{}, fmt.Errorf("starter %q: one of %s, or hf:<owner>/<repo>/<file>.gguf", s, strings.Join(names, ", "))
}
func starterNames() []string {
var out []string
for _, c := range starterChoices {
out = append(out, c.Name)
}
return out
}
// readChoices takes the flags that are not read straight into init: the
// starter by its name, the judge's categories by commas. Then it checks them
// all.
func (in *serverInit) readChoices(starter, categories string) error {
if strings.TrimSpace(starter) != "" {
s, err := starterFrom(starter)
if err != nil {
return err
}
in.starter = s
}
in.judge.Categories = nil
for _, c := range strings.Split(categories, ",") {
if c = strings.TrimSpace(c); c != "" {
in.judge.Categories = append(in.judge.Categories, c)
}
}
return in.checkChoices()
}
// checkChoices says what among the choices cannot be installed.
func (in *serverInit) checkChoices() error {
switch in.language {
case "", "en", "tr":
default:
return fmt.Errorf("language %q: en or tr", in.language)
}
switch in.judge.Fail {
case "", "closed", "open":
default:
return fmt.Errorf("judge fail %q: closed or open", in.judge.Fail)
}
for _, c := range in.judge.Categories {
if !slices.Contains(judgeCategories, c) {
return fmt.Errorf("judge category %q: one of %s", c, strings.Join(judgeCategories, ", "))
}
}
switch {
case in.judge.Replicas < 0 || in.judge.Replicas > 8:
return fmt.Errorf("judge replicas %d: from 1 to 8", in.judge.Replicas)
case in.judge.Budget < 0 || in.judge.Budget > time.Minute:
return fmt.Errorf("judge budget %s: at most a minute", in.judge.Budget)
case in.starterCtx < 0 || in.starterCtx > 262144:
return fmt.Errorf("starter context %d: at most 262144 tokens", in.starterCtx)
case in.starterCtx > 0 && in.starterCtx < 512:
return fmt.Errorf("starter context %d: at least 512 tokens", in.starterCtx)
case in.defaultRPM < 0 || in.defaultTPM < 0:
return fmt.Errorf("default limits cannot be negative")
case in.maxKeyTTL < 0 || (in.maxKeyTTL > 0 && in.maxKeyTTL < time.Hour):
return fmt.Errorf("max key ttl %s: an hour at least", in.maxKeyTTL)
case in.evictAfterIdle < 0 || in.minLifetime < 0:
return fmt.Errorf("on-demand times cannot be negative")
case in.vip != "" && !in.standbys:
return fmt.Errorf("a floating address moves between control planes: it needs the standbys")
}
if in.vip != "" {
ip, _, err := net.ParseCIDR(in.vip)
if err != nil || ip.To4() == nil {
return fmt.Errorf("floating address %q: an IPv4 address with its prefix, as 10.0.0.160/24", in.vip)
}
}
return nil
}
// starterModelSpec is the starter this install serves.
func (in *serverInit) starterModelSpec() starterSpec {
if in.starter.Source == "" {
return starterChoices[0]
}
return in.starter
}
// minMemoryGB is what a node needs free for the starter: its weights and
// room for its context, never less than the 2 GB it always asked.
func (s starterSpec) minMemoryGB() int {
return max(2, int(math.Ceil(s.SizeGB+1.5)))
}
// defaultContext is the context a client may use of the starter.
const defaultContext = 4096
// starterContext is the context a client may use of the starter.
func (in *serverInit) starterContext() int {
if in.starterCtx > 0 {
return in.starterCtx
}
return defaultContext
}
// judgeSpec is how the guardrails' judge decides.
type judgeSpec struct {
// Fail is what is done with a request no judge read in time: closed
// refuses it, open serves it flagged.
Fail string
Categories []string
Replicas int
Budget time.Duration
}
// judgeCategories are the ones a judge can be asked about, as Qwen3Guard
// names them; defaultJudgeCategories those it is asked about unless told.
var (
judgeCategories = []string{"Violent", "Non-violent Illegal Acts", "Sexual Content or Sexual Acts",
"Suicide & Self-Harm", "Jailbreak", "PII", "Unethical Acts", "Politically Sensitive Topics", "Copyright Violation"}
defaultJudgeCategories = judgeCategories[:5]
)
// judgeFor is the judge as this install sets it up, the defaults filled in.
func (in *serverInit) judgeFor() judgeSpec {
j := in.judge
if j.Fail == "" {
j.Fail = "closed"
}
if len(j.Categories) == 0 {
j.Categories = defaultJudgeCategories
}
if j.Replicas <= 0 {
j.Replicas = 2
}
if j.Budget <= 0 {
j.Budget = 5 * time.Second
}
return j
}
// The configuration's limits, as init writes them unless told otherwise.
const (
defaultRPM = 60
defaultTPM = 200000
defaultMaxKeyTTL = 2160 * time.Hour
)
// tuneConfig writes this install's choices into the server.yaml template:
// the lines the template has, changed, and the blocks it has not, added.
// Unchosen, the template comes back as it was.
func (in *serverInit) tuneConfig(body string) string {
set := func(old, now string) {
if old != now {
body = strings.Replace(body, old, now, 1)
}
}
if in.signupOpen {
set(" signup_open: false", " signup_open: true")
}
if in.defaultRPM > 0 {
set(" default_rpm: 60", " default_rpm: "+strconv.Itoa(in.defaultRPM))
}
if in.defaultTPM > 0 {
set(" default_tpm: 200000", " default_tpm: "+strconv.Itoa(in.defaultTPM))
}
if in.maxKeyTTL > 0 && in.maxKeyTTL != defaultMaxKeyTTL {
set(" max_key_ttl: 2160h # 90 days", fmt.Sprintf(" max_key_ttl: %s # %s", durationYAML(in.maxKeyTTL), days(in.maxKeyTTL)))
}
if in.language != "" {
set(" # language: tr", " language: "+in.language)
}
var extra []string
if in.media {
extra = append(extra, "# Pictures and 3D, chosen at install: the nodes install ComfyUI, and the\n"+
"# leader adds a picture model and a 3D model by itself once a card fits\n"+
"# one ('llmgw ctl model presets auto off' stops it).\n"+
"engines:\n comfyui: "+provision.ComfyUIVersion()+"\npresets:\n auto: true\n")
}
if in.evictAfterIdle > 0 || in.minLifetime > 0 {
b := "# How models that load when asked for take turns on a card, chosen at\n# install.\non_demand:\n"
if in.evictAfterIdle > 0 {
b += " evict_after_idle: " + durationYAML(in.evictAfterIdle) + "\n"
}
if in.minLifetime > 0 {
b += " min_lifetime: " + durationYAML(in.minLifetime) + "\n"
}
extra = append(extra, b)
}
if len(extra) > 0 {
body += "\n" + strings.Join(extra, "\n")
}
return body
}
// durationYAML is a duration as a person writes it: 2h, 90m, 1m30s.
func durationYAML(d time.Duration) string {
switch {
case d == 0:
return "0s"
case d%time.Hour == 0:
return fmt.Sprintf("%dh", d/time.Hour)
case d%time.Minute == 0:
return fmt.Sprintf("%dm", d/time.Minute)
}
return d.String()
}
func days(d time.Duration) string {
n := int(d.Hours() / 24)
if n == 1 {
return "1 day"
}
return fmt.Sprintf("%d days", n)
}
// initFlags is this install as `llmgw server init`'s flags, for doing it
// again from a script; what is left at its default is left out.
func (in *serverInit) initFlags() []string {
var out []string
add := func(flag string, v ...string) {
if len(v) == 0 {
out = append(out, flag)
return
}
out = append(out, flag+" "+shellWord(v[0]))
}
if in.admin != "" {
add("--admin", in.admin)
}
for _, h := range in.hosts {
add("--host", h)
}
if in.language != "" {
add("--language", in.language)
}
if in.signupOpen {
add("--signup-open")
}
if in.defaultRPM > 0 && in.defaultRPM != defaultRPM {
add("--default-rpm", strconv.Itoa(in.defaultRPM))
}
if in.defaultTPM > 0 && in.defaultTPM != defaultTPM {
add("--default-tpm", strconv.Itoa(in.defaultTPM))
}
if in.maxKeyTTL > 0 && in.maxKeyTTL != defaultMaxKeyTTL {
add("--max-key-ttl", durationYAML(in.maxKeyTTL))
}
if in.postgres {
add("--postgres")
}
for _, t := range in.patroni {
add("--patroni", t)
}
switch {
case in.mirrorURL != "":
add("--mirror", in.mirrorURL)
case in.noMirror:
add("--no-mirror")
case in.mirrorPath != "" && in.mirrorPath != mirrorDir:
add("--mirror-dir", in.mirrorPath)
}
if in.bundle != "" {
add("--bundle", in.bundle)
add("--bundle-sha256", in.bundleSHA)
}
if in.noNode {
add("--no-node")
}
if in.noModel {
add("--no-model")
} else {
if s := in.starterModelSpec(); s.Name != starterName {
v := s.Name
if !slices.ContainsFunc(starterChoices, func(c starterSpec) bool { return c.Name == s.Name }) {
v = s.Source
}
add("--starter", v)
}
if in.starterCtx > 0 && in.starterCtx != defaultContext {
add("--starter-context", strconv.Itoa(in.starterCtx))
}
}
if in.noGuardModel {
add("--no-guard-model")
} else {
j, d := in.judgeFor(), (&serverInit{}).judgeFor()
if j.Fail != d.Fail {
add("--judge-fail", j.Fail)
}
if !slices.Equal(j.Categories, d.Categories) {
add("--judge-categories", strings.Join(j.Categories, ","))
}
if j.Replicas != d.Replicas {
add("--judge-replicas", strconv.Itoa(j.Replicas))
}
if j.Budget != d.Budget {
add("--judge-budget", durationYAML(j.Budget))
}
}
if in.noFirewall {
add("--no-firewall")
}
if in.standbys {
add("--standbys")
}
if in.vip != "" {
add("--vip", in.vip)
}
for _, w := range in.webhooks {
for _, s := range strings.Split(w, ",") {
if s = strings.TrimSpace(s); s != "" {
add("--webhook", s)
}
}
}
if in.media {
add("--media")
}
if in.evictAfterIdle > 0 {
add("--evict-after-idle", durationYAML(in.evictAfterIdle))
}
if in.minLifetime > 0 {
add("--min-lifetime", durationYAML(in.minLifetime))
}
return out
}
// shellWord quotes a word for a shell when it needs it.
func shellWord(s string) string {
if s != "" && !strings.ContainsAny(s, " \t\n'\"\\$`&;|<>(){}*?[]#~!") {
return s
}
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
+208
View File
@@ -0,0 +1,208 @@
package cli
import (
"context"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
)
func TestEveryChoiceIsInTheConfigurationWrittenAndItLoads(t *testing.T) {
in := &serverInit{
language: "tr", signupOpen: true, defaultRPM: 120, defaultTPM: 50000, maxKeyTTL: 30 * 24 * time.Hour,
media: true, evictAfterIdle: time.Minute, minLifetime: 90 * time.Second,
judge: judgeSpec{Fail: "open", Categories: []string{"Violent", "PII"}, Replicas: 3, Budget: 3 * time.Second},
}
j := in.judgeFor()
body := in.tuneConfig(fmt.Sprintf(serverConfigTemplate, "/etc/llmgw", "/etc/llmgw", "/etc/llmgw",
"/var/lib/llmgw/llmgw.db", "ops@example.com", guardSectionFor(&j)))
path := filepath.Join(t.TempDir(), "server.yaml")
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
cfg, err := config.Load(path)
if err != nil {
t.Fatalf("%v\n%s", err, body)
}
if err := cfg.Validate(); err != nil {
t.Fatal(err)
}
switch {
case cfg.Landing.Language != "tr" || !cfg.Auth.SignupOpen:
t.Errorf("language %q signup %v", cfg.Landing.Language, cfg.Auth.SignupOpen)
case cfg.Auth.DefaultRPM != 120 || cfg.Auth.DefaultTPM != 50000 || cfg.Auth.MaxKeyTTL != 30*24*time.Hour:
t.Errorf("limits %d %d %s", cfg.Auth.DefaultRPM, cfg.Auth.DefaultTPM, cfg.Auth.MaxKeyTTL)
case !cfg.Presets.Auto || cfg.Engines["comfyui"] == "":
t.Errorf("pictures and 3D: presets %v comfyui %q", cfg.Presets.Auto, cfg.Engines["comfyui"])
case cfg.OnDemand.EvictAfterIdle != time.Minute || cfg.OnDemand.MinLifetime != 90*time.Second:
t.Errorf("on demand %+v", cfg.OnDemand)
}
base := cfg.Guard.Policies["baseline"]
if base.Fail != "open" || base.Judge == nil || !slices.Equal(base.Judge.Categories, []string{"Violent", "PII"}) ||
base.Judge.LatencyBudget != 3*time.Second {
t.Errorf("the judge as written: fail %q %+v", base.Fail, base.Judge)
}
if !strings.Contains(body, "Three replicas run on CPUs") {
t.Error("the comment says another number of judges than run")
}
}
func TestNothingChosenIsTheTemplateAsItWas(t *testing.T) {
body := fmt.Sprintf(serverConfigTemplate, "/e", "/e", "/e", "/s.db", "a@b", guardSection(true))
if got := (&serverInit{}).tuneConfig(body); got != body {
t.Error("an install with nothing chosen writes another configuration than before")
}
// Each line tuneConfig changes is in the template, or a choice would be
// lost without a word.
for _, line := range []string{" signup_open: false", " default_rpm: 60", " default_tpm: 200000",
" max_key_ttl: 2160h # 90 days", " # language: tr"} {
if !strings.Contains(serverConfigTemplate, line) {
t.Errorf("the template has no %q", line)
}
}
}
func TestAStarterIsChosenByNameOrByItsFile(t *testing.T) {
s, err := starterFrom("qwen3-8b")
if err != nil || s.SizeGB != 5.0 || !strings.HasPrefix(s.Source, "hf:Qwen/Qwen3-8B-GGUF/") {
t.Errorf("by name: %+v %v", s, err)
}
s, err = starterFrom("hf:bartowski/Phi-4-GGUF/Phi-4-Q4_K_M.gguf")
if err != nil || s.Name != "phi-4-q4_k_m" {
t.Errorf("by file: %+v %v", s, err)
}
if _, err := starterFrom("gpt-5"); err == nil {
t.Error("a model nobody offers was taken")
}
m := starterModel(s, 8192, 0, "http://x/p.gguf", []string{"6000-24g"}, true)
if m.Name != "phi-4-q4_k_m" || m.ContextLength != 8192 || len(m.Aliases) != 0 ||
!slices.Equal(m.Profiles[0].Args, []string{"--ctx-size", "32768"}) {
t.Errorf("entry %+v", m)
}
big, _ := starterFrom("qwen3-14b")
if m := starterModel(big, 16384, 0, "http://x/q.gguf", nil, true); m.Profiles[0].MinMemoryGB != 11 ||
!slices.Equal(m.Profiles[0].Args, []string{"--ctx-size", "65536"}) {
t.Errorf("a 9 GB starter asks for %d GB, %v", m.Profiles[0].MinMemoryGB, m.Profiles[0].Args)
}
}
func TestTheChoicesAreTheirFlagsAgain(t *testing.T) {
in := &serverInit{admin: "ops@example.com", hosts: []string{"llm.example.com"}, postgres: true,
mirrorPath: "/data/models", noNode: true, language: "tr", media: true,
webhooks: []string{"https://hooks.slack.com/services/T/B/x, json:https://a.example.com/in"},
judge: judgeSpec{Categories: []string{"Violent", "Jailbreak"}}, maxKeyTTL: 7 * 24 * time.Hour}
in.starter, _ = starterFrom("qwen3-4b")
got := strings.Join(in.initFlags(), " ")
want := "--admin ops@example.com --host llm.example.com --language tr --max-key-ttl 168h --postgres " +
"--mirror-dir /data/models --no-node --starter qwen3-4b --judge-categories Violent,Jailbreak " +
"--webhook https://hooks.slack.com/services/T/B/x --webhook json:https://a.example.com/in --media"
if got != want {
t.Errorf("flags\n got %s\nwant %s", got, want)
}
if got := strings.Join((&serverInit{}).initFlags(), " "); got != "" {
t.Errorf("nothing chosen is %q", got)
}
if w := shellWord("Non-violent Illegal Acts,PII"); w != "'Non-violent Illegal Acts,PII'" {
t.Errorf("quoted %s", w)
}
}
func TestChoicesThatCannotBeInstalledAreRefused(t *testing.T) {
for _, in := range []serverInit{
{language: "de"},
{judge: judgeSpec{Fail: "maybe"}},
{judge: judgeSpec{Categories: []string{"Rudeness"}}},
{judge: judgeSpec{Replicas: 9}},
{starterCtx: 100},
{maxKeyTTL: time.Minute},
{vip: "10.0.0.160/24"},
{standbys: true, vip: "10.0.0.160"},
} {
if err := in.checkChoices(); err == nil {
t.Errorf("taken: %+v", in)
}
}
if err := (&serverInit{standbys: true, vip: "10.0.0.160/24"}).checkChoices(); err != nil {
t.Error(err)
}
}
func TestWithStandbysTheMirrorAnswersEveryControlPlane(t *testing.T) {
in := &serverInit{hosts: []string{"10.0.0.5"}, patroniNodes: []pgha.Node{
{Member: pgha.Member{Name: "db1", IP: "10.0.0.6"}}, {Member: pgha.Member{Name: "db2", IP: "10.0.0.7"}},
}}
if u := in.mirrorUnit(); !strings.Contains(u, "--api-listen 127.0.0.1:8082") || strings.Contains(u, "--api-allow") {
t.Errorf("one control plane: the API is for this machine only:\n%s", u)
}
in.standbys = true
u := in.mirrorUnit()
for _, want := range []string{"--api-listen :8082", "--api-allow 10.0.0.5", "--api-allow 10.0.0.6", "--api-allow 10.0.0.7"} {
if !strings.Contains(u, want) {
t.Errorf("the unit lacks %q:\n%s", want, u)
}
}
if got := in.mirrorAPIURL(); got != "http://10.0.0.5:8082" {
t.Errorf("the control planes reach the mirror at %s", got)
}
}
func TestTheFloatingAddressIsInTheCertificate(t *testing.T) {
in := &serverInit{vip: "10.0.0.160/24"}
if !slices.Contains(in.certHosts(), "10.0.0.160") {
t.Errorf("hosts %v", in.certHosts())
}
}
func TestStandbysWantTheStoresMachines(t *testing.T) {
in := &serverInit{standbys: true, out: &strings.Builder{}}
if err := in.ensureStandbys(context.Background()); err == nil || !strings.Contains(err.Error(), "--patroni") {
t.Errorf("standbys with nowhere to go: %v", err)
}
}
func TestTwoClustersOnOneNetworkKeepApartFloatingAddresses(t *testing.T) {
a, b := vrrpRouterID("10.0.0.161"), vrrpRouterID("10.0.0.164")
if a == b || a < 1 || a > 255 || b < 1 || b > 255 {
t.Errorf("router ids %d and %d", a, b)
}
conf := keepalivedConf("ens18", "10.0.0.164", 24, 100, "secret12")
if !strings.Contains(conf, fmt.Sprintf("virtual_router_id %d\n", b)) || !strings.Contains(conf, "interface ens18") {
t.Errorf("conf:\n%s", conf)
}
}
func TestTheJudgesAndTheStarterShareTheCPUsRatherThanEachTakingAll(t *testing.T) {
for _, c := range []struct {
in serverInit
judge, starter int
}{
{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
{serverInit{cpus: 1}, 0, 0},
{serverInit{cpus: 4, noGuardModel: true}, 1, 4},
{serverInit{cpus: 8, judge: judgeSpec{Replicas: 3}}, 2, 2},
{serverInit{cpus: 4, noNode: true}, 2, 0}, // on nodes not known yet
} {
got := c.in.cpuPlan()
if got.judge != c.judge || got.starter != c.starter {
t.Errorf("%+v: judge %d starter %d, want %d %d", c.in, got.judge, got.starter, c.judge, c.starter)
}
}
m := guardModel("http://x/g.gguf", 2, 1)
if p := m.Profiles[0]; p.CPUCores != 1 || p.MinMemoryGB != 4 {
t.Errorf("judge profile: %d cores, %d GB", p.CPUCores, p.MinMemoryGB)
}
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)
}
}
+113 -28
View File
@@ -9,7 +9,9 @@ import (
"os"
"os/exec"
"path/filepath"
"runtime"
"slices"
"strconv"
"strings"
"time"
@@ -120,10 +122,36 @@ func (in *serverInit) writeUnit(path, body string) (bool, error) {
func (in *serverInit) mirrorUnit() string {
base := "http://" + net.JoinHostPort(in.reachableAddr(), "8081")
return mirrorUnitFor(mirrorDir, mirrorAPI, base, nil,
if in.standbys {
return mirrorUnitFor(in.mirrorRoot(), ":8082", base, in.mirrorAllow(),
"'llmgw server init'. Bytes on :8081 for every node; the\n# management API for the control planes only, whichever leads.")
}
return mirrorUnitFor(in.mirrorRoot(), mirrorAPI, base, nil,
"'llmgw server init'. Bytes on :8081 for every node; the\n# management API on loopback only, for the control plane beside it.")
}
// localMirrorAPI is the management API of the mirror beside this control
// plane: on loopback, or, with standbys that may lead instead, on an address
// they reach.
func (in *serverInit) localMirrorAPI() string {
if in.standbys {
return "http://" + net.JoinHostPort(in.reachableAddr(), "8082")
}
return "http://" + mirrorAPI
}
// mirrorAllow are the control planes the mirror's management API answers
// besides loopback: this one by its own address, and each standby.
func (in *serverInit) mirrorAllow() []string {
out := []string{in.reachableAddr()}
for _, n := range in.patroniNodes {
if !slices.Contains(out, n.IP) {
out = append(out, n.IP)
}
}
return out
}
// mirrorUnitFor is a mirror's systemd unit. allow is who, besides the
// machine itself, may use the management API.
func mirrorUnitFor(dir, apiListen, base string, allow []string, about string) string {
@@ -160,7 +188,7 @@ func (in *serverInit) mirrorAPIURL() string {
if in.mirrorURL != "" {
return in.mirrorURL
}
return "http://" + mirrorAPI
return in.localMirrorAPI()
}
// useMirror points the control plane at a mirror on another machine, set up
@@ -202,15 +230,16 @@ func (in *serverInit) ensureMirror(ctx context.Context) error {
if err != nil {
return err
}
if cfg.Mirror.API != "" && cfg.Mirror.API != "http://"+mirrorAPI {
if cfg.Mirror.API != "" && cfg.Mirror.API != "http://"+mirrorAPI && cfg.Mirror.API != in.localMirrorAPI() {
in.kept("mirror at %s, as server.yaml says", cfg.Mirror.API)
return nil
}
if err := os.MkdirAll(mirrorDir, 0o755); err != nil {
dir := in.mirrorRoot()
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
if err := in.chown(mirrorDir, true); err != nil {
if err := in.chown(dir, true); err != nil {
return err
}
changed, err := in.writeUnit(mirrorUnitPath, in.mirrorUnit())
@@ -227,21 +256,25 @@ func (in *serverInit) ensureMirror(ctx context.Context) error {
if err := in.systemctl(ctx, "restart", "llmgw-mirror"); err != nil {
return err
}
in.done("model mirror in %s, serving on :8081", mirrorDir)
in.done("model mirror in %s, serving on :8081", dir)
} else {
in.kept("model mirror in %s", mirrorDir)
in.kept("model mirror in %s", dir)
}
if cfg.Mirror.API == "" {
if err := appendMirrorBlockFor(in.configPath(), "http://"+mirrorAPI,
"# The model mirror on this machine, added by 'llmgw server init'. Its\n"+
"# management API is on loopback, so it needs no certificate."); err != nil {
comment := "# The model mirror on this machine, added by 'llmgw server init'. Its\n" +
"# management API is on loopback, so it needs no certificate."
if in.standbys {
comment = "# The model mirror on this machine, added by 'llmgw server init'. Its\n" +
"# management API answers the control planes' addresses only."
}
if err := appendMirrorBlockFor(in.configPath(), in.localMirrorAPI(), comment); err != nil {
return err
}
in.done("server.yaml points at the mirror")
}
m, err := mirror.NewClient("http://"+mirrorAPI, tlsconf.Config{}, 5*time.Second)
m, err := mirror.NewClient(in.localMirrorAPI(), tlsconf.Config{}, 5*time.Second)
if err != nil {
return err
}
@@ -414,8 +447,9 @@ func hasNVIDIADevice() bool {
// --- the starter model ---
// starterModel is the catalog entry: a GPU profile first when there is a card,
// and a CPU profile always, both pinned to this machine.
func starterModel(source string, gpuClasses []string, pinned bool) catalog.Model {
// and a CPU profile always, both pinned to this machine. ctx is the context a
// client may use; each engine holds four times that, for four at once.
func starterModel(spec starterSpec, ctx, cores int, source string, gpuClasses []string, pinned bool) catalog.Model {
var pin map[string]string
if pinned {
pin = map[string]string{localLabelKey: localLabelValue}
@@ -424,21 +458,25 @@ func starterModel(source string, gpuClasses []string, pinned bool) catalog.Model
for _, class := range gpuClasses {
profiles = append(profiles, catalog.Profile{
Name: "gpu-" + class, Engine: "llamacpp", GPUClass: class,
Priority: 200, MinMemoryGB: 2, NodeSelector: pin,
Args: []string{"--ctx-size", "32768"},
Priority: 200, MinMemoryGB: spec.minMemoryGB(), NodeSelector: pin,
Args: []string{"--ctx-size", strconv.Itoa(max(32768, 4*ctx))},
})
}
profiles = append(profiles, catalog.Profile{
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: 2, NodeSelector: pin,
Args: []string{"--ctx-size", "16384"},
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: spec.minMemoryGB(), CPUCores: cores, NodeSelector: pin,
Args: []string{"--ctx-size", strconv.Itoa(max(16384, 4*ctx))},
})
var aliases []string
if spec.Alias != "" {
aliases = []string{spec.Alias}
}
return catalog.Model{
Name: starterName,
Aliases: []string{starterAlias},
Description: "A small model init put here so there is something to try; fast, not clever.",
Name: spec.Name,
Aliases: aliases,
Description: "The model init put here so there is something to try: " + spec.About + ".",
Source: source,
RoutingProfile: "chat",
ContextLength: 4096,
ContextLength: ctx,
MaxOutput: 1024,
Scaling: catalog.Scaling{Min: 1, Max: 1},
Profiles: profiles,
@@ -446,8 +484,8 @@ func starterModel(source string, gpuClasses []string, pinned bool) catalog.Model
}
// guardModel is the judge's catalog entry: CPU only, so it takes no card from
// the models people use, on whichever node has room.
func guardModel(source string) catalog.Model {
// the models people use, on whichever node has room; replicas copies of it.
func guardModel(source string, replicas, cores int) catalog.Model {
return catalog.Model{
Name: guardModelName,
Description: "The guardrails' judge: reads prompts and answers for unsafe content. Not offered to clients.",
@@ -456,9 +494,11 @@ func guardModel(source string) catalog.Model {
Guard: &catalog.GuardModel{Format: "qwen3guard"},
// Two: every judged request waits on it, and with fail: closed a
// judge that is not there refuses everything.
Scaling: catalog.Scaling{Min: 2, Max: 2},
Scaling: catalog.Scaling{Min: replicas, Max: replicas},
Profiles: []catalog.Profile{{
Name: "cpu", Engine: "llamacpp", Priority: 100, MinMemoryGB: 2,
// 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"},
}},
}
@@ -469,15 +509,20 @@ func (in *serverInit) ensureStarter(ctx context.Context) error {
// Served here when this machine is a node, pinned so it does not wander
// to the first GPU node that joins; otherwise on whichever node joins.
pinned := !in.noNode
return in.ensureServed(ctx, starterName, starterSource, starterVersion, pinned,
func(url string, classes []string) catalog.Model { return starterModel(url, classes, pinned) })
spec, n := in.starterModelSpec(), in.starterContext()
return in.ensureServed(ctx, spec.Name, spec.Source, starterVersion, pinned,
func(url string, classes []string) catalog.Model {
return starterModel(spec, n, in.cpuPlan().starter, url, classes, pinned)
})
}
// ensureGuardModel fetches the guardrails' judge model into the mirror and
// adds it to the catalog, to run on any node with room.
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) })
func(url string, _ []string) catalog.Model {
return guardModel(url, in.judgeFor().Replicas, in.cpuPlan().judge)
})
}
// ensureServed fetches a model into the mirror, adds it to the catalog, and
@@ -661,3 +706,43 @@ func waitFor(ctx context.Context, limit time.Duration, what string, done func()
}
}
}
// cpuSplit is the cores init's models reserve: each judge replica's, and
// the starter's on CPU.
type cpuSplit struct{ judge, starter int }
// cpuPlan divides the CPUs between the judges and the starter, so that the
// engines do not each take every core. Without it, two judge replicas and
// the starter on one 4-core control plane all ran on all four: under load
// most judgments missed their budget and the requests were refused.
//
// 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.
func (in *serverInit) cpuPlan() cpuSplit {
if in.noNode {
return cpuSplit{judge: 2}
}
n := in.cpus
if n <= 0 {
n = runtime.NumCPU()
}
if n < 2 {
return cpuSplit{}
}
judges := 0
if !in.noGuardModel {
judges = in.judgeFor().Replicas
}
judge := 1
if n >= 8 {
judge = 2
}
left := n - judges*judge
if left < 1 {
left = 0
}
return cpuSplit{judge: judge, starter: min(left, 8)}
}
+4 -4
View File
@@ -28,7 +28,7 @@ func TestTheInitAgentUnitIsTheShippedUnit(t *testing.T) {
}
func TestTheStarterUsesTheGPUAndFallsBackToCPUOnThisMachine(t *testing.T) {
m := starterModel("http://10.0.0.5:8081/qwen2.5-0.5b-instruct/starter/q.gguf", []string{"4090-24g"}, true)
m := starterModel(starterChoices[0], defaultContext, 0, "http://10.0.0.5:8081/qwen2.5-0.5b-instruct/starter/q.gguf", []string{"4090-24g"}, true)
c, err := catalog.New([]catalog.Model{m})
if err != nil {
t.Fatalf("the starter is not a valid catalog entry: %v", err)
@@ -53,7 +53,7 @@ func TestTheStarterUsesTheGPUAndFallsBackToCPUOnThisMachine(t *testing.T) {
t.Error("the starter should be running, not waiting to be asked for")
}
if m := starterModel("http://x/q.gguf", nil, true); len(m.Profiles) != 1 || m.Profiles[0].Name != "cpu" {
if m := starterModel(starterChoices[0], defaultContext, 0, "http://x/q.gguf", nil, true); len(m.Profiles) != 1 || m.Profiles[0].Name != "cpu" {
t.Errorf("without a GPU only the CPU profile: %+v", m.Profiles)
}
}
@@ -96,7 +96,7 @@ func TestTheMirrorUnitAdvertisesAnAddressNodesCanReach(t *testing.T) {
}
func TestWithNoNodeHereTheStarterGoesToTheFirstNodeThatJoins(t *testing.T) {
m := starterModel("http://x/q.gguf", nil, false)
m := starterModel(starterChoices[0], defaultContext, 0, "http://x/q.gguf", nil, false)
for _, p := range m.Profiles {
if len(p.NodeSelector) > 0 {
t.Errorf("profile %s is pinned to a machine that is not a node: %v", p.Name, p.NodeSelector)
@@ -171,7 +171,7 @@ func TestTheInstalledConfigurationIsHardenedAndLoads(t *testing.T) {
}
func TestTheGuardModelIsAValidJudge(t *testing.T) {
m := guardModel("http://10.0.0.5:8081/qwen3guard-0.6b/guard/g.gguf")
m := guardModel("http://10.0.0.5:8081/qwen3guard-0.6b/guard/g.gguf", 2, 2)
c, err := catalog.New([]catalog.Model{m})
if err != nil {
t.Fatalf("not a valid catalog entry: %v", err)
+185
View File
@@ -0,0 +1,185 @@
package cli
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"slices"
"strings"
"time"
"go.yaml.in/yaml/v3"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
)
// Where the cluster says what went wrong: a model parked, a node lost, a
// budget spent. notify.webhooks existed, and setup never asked for one, so a
// site learnt of a lost node from its users.
// parseWebhook reads a webhook as setup takes it: an address, with "slack:"
// or "json:" in front to say what it is sent, else what its address says.
func parseWebhook(s string) (notify.Webhook, error) {
s = strings.TrimSpace(s)
format := ""
for _, f := range []string{"slack", "json"} {
if rest, ok := strings.CutPrefix(s, f+":"); ok && !strings.HasPrefix(rest, "//") {
format, s = f, rest
}
}
u, err := url.Parse(s)
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
return notify.Webhook{}, fmt.Errorf("webhook %q: an http(s) address, with slack: or json: in front to say its format", s)
}
if format == "" {
format = webhookFormat(u)
}
return notify.Webhook{URL: s, Format: format}, nil
}
// webhookFormat is what an address takes: Slack's own, and the incoming
// webhooks that read Slack's {"text": …} — Mattermost's and Rocket.Chat's
// /hooks/, Discord's …/slack — are sent that; anything else the event as
// JSON.
func webhookFormat(u *url.URL) string {
switch {
case u.Host == "hooks.slack.com",
strings.Contains(u.Path, "/hooks/"),
strings.HasSuffix(u.Path, "/slack"):
return "slack"
}
return "json"
}
// parseWebhooks reads a list of them, by commas or as given.
func parseWebhooks(list []string) ([]notify.Webhook, error) {
var out []notify.Webhook
for _, item := range list {
for _, s := range strings.Split(item, ",") {
if strings.TrimSpace(s) == "" {
continue
}
w, err := parseWebhook(s)
if err != nil {
return nil, err
}
out = append(out, w)
}
}
return out, nil
}
// webhookEntry is one of notify.webhooks as the settings document has it.
type webhookEntry struct {
URL string `yaml:"url"`
Format string `yaml:"format,omitempty"`
Events []string `yaml:"events,omitempty,flow"`
SecretFile string `yaml:"secret_file,omitempty"`
}
// withWebhooks adds these webhooks to a settings document's notify.webhooks,
// and says whether that changed it: one already there, by address, is left
// as it is, with whatever events and secret it was given.
func withWebhooks(doc *settings.Doc, add []notify.Webhook) (bool, error) {
path := []string{"notify", "webhooks"}
var list []webhookEntry
if n := doc.Node(path); n != nil {
if err := n.Decode(&list); err != nil {
return false, fmt.Errorf("notify.webhooks in the cluster's settings: %w", err)
}
}
changed := false
for _, w := range add {
if !slices.ContainsFunc(list, func(e webhookEntry) bool { return e.URL == w.URL }) {
list = append(list, webhookEntry{URL: w.URL, Format: w.Format})
changed = true
}
}
if !changed {
return false, nil
}
b, err := yaml.Marshal(list)
if err != nil {
return false, err
}
return true, doc.SetYAML(path, string(b))
}
// ensureWebhooks puts the webhooks given into the cluster's settings and
// sends each a test, once the control plane runs on them. Said, not fatal: a
// cluster without its alerts still serves.
func (in *serverInit) ensureWebhooks(ctx context.Context) error {
if len(in.webhooks) == 0 {
return nil
}
hooks, err := parseWebhooks(in.webhooks)
if err != nil {
return err
}
c := in.adminAPI()
var cur adminapi.SettingsView
if err := c.get(ctx, "/api/v1/settings", &cur); err != nil {
return err
}
doc, err := settings.FromBytes("settings", []byte(cur.Body))
if err != nil {
return err
}
changed, err := withWebhooks(doc, hooks)
if err != nil {
return err
}
if changed {
b, err := doc.Bytes()
if err != nil {
return err
}
req, _ := json.Marshal(adminapi.SettingsRequest{Base: cur.Version, Body: string(b), Note: "notification webhooks, from setup"})
if err := c.put(ctx, "/api/v1/settings", req, &cur); err != nil {
return err
}
in.done("notification webhooks in the cluster's settings (version %d)", cur.Version)
} else {
in.kept("notification webhooks (already in the cluster's settings)")
}
// The control planes start again on the change; the test is sent by
// one that knows every webhook asked for.
var res []notify.Result
err = waitFor(ctx, 2*time.Minute, "the control plane to run on its webhooks", func() (bool, error) {
res = nil
if c.post(ctx, "/api/v1/notify/test", nil, &res) != nil {
return false, nil
}
for _, w := range hooks {
// Answered by host: the path of a webhook is its secret.
if !slices.ContainsFunc(res, func(r notify.Result) bool { return r.URL == notify.Redact(w.URL) }) {
return false, nil
}
}
return true, nil
})
if err != nil {
return err
}
var failed []string
for _, r := range res {
if !slices.ContainsFunc(hooks, func(w notify.Webhook) bool { return notify.Redact(w.URL) == r.URL }) {
continue
}
if r.Err != "" {
failed = append(failed, r.URL+": "+r.Err)
continue
}
in.done("a test notification reached %s", r.URL)
}
if len(failed) > 0 {
return errors.New("a test notification did not arrive: " + strings.Join(failed, "; ") +
" (kept in the settings: 'llmgw ctl notify test' tries again)")
}
return nil
}
+122
View File
@@ -0,0 +1,122 @@
package cli
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
)
func TestAWebhooksFormatIsWhatItsAddressTakes(t *testing.T) {
for in, want := range map[string]string{
"https://hooks.slack.com/services/T1/B2/abc": "slack",
"https://chat.example.com/hooks/xyz": "slack", // Mattermost, Rocket.Chat
"https://discord.com/api/webhooks/1/abc/slack": "slack",
"https://alerts.example.com/llmgw": "json",
"json:https://hooks.slack.com/services/T1/B2/abc": "json",
"slack:https://alerts.example.com/llmgw": "slack",
" https://events.pagerduty.com/integration/x/enqueue ": "json",
} {
w, err := parseWebhook(in)
if err != nil || w.Format != want || strings.HasPrefix(w.URL, "slack:") || strings.HasPrefix(w.URL, "json:") {
t.Errorf("%q: %+v %v, want %s", in, w, err, want)
}
}
for _, bad := range []string{"hooks.slack.com/x", "ftp://example.com/x", "slack:", "https://"} {
if _, err := parseWebhook(bad); err == nil {
t.Errorf("%q was taken", bad)
}
}
}
func TestWebhooksAreAddedToTheSettingsBesideTheOnesThere(t *testing.T) {
doc, err := settings.FromBytes("settings", []byte("router:\n retries: 2\nnotify:\n webhooks:\n"+
" - url: https://alerts.example.com/a\n events: [node_lost]\n secret_file: /etc/llmgw/secrets/hook\n"))
if err != nil {
t.Fatal(err)
}
hooks, _ := parseWebhooks([]string{"https://alerts.example.com/a, https://hooks.slack.com/services/T/B/c"})
changed, err := withWebhooks(doc, hooks)
if err != nil || !changed {
t.Fatalf("changed %v: %v", changed, err)
}
b, _ := doc.Bytes()
got := string(b)
for _, want := range []string{"retries: 2", "url: https://alerts.example.com/a", "events: [node_lost]",
"secret_file: /etc/llmgw/secrets/hook", "url: https://hooks.slack.com/services/T/B/c", "format: slack"} {
if !strings.Contains(got, want) {
t.Errorf("%q not in:\n%s", want, got)
}
}
if strings.Count(got, "alerts.example.com/a") != 1 {
t.Errorf("a webhook already there was added again:\n%s", got)
}
if changed, _ := withWebhooks(doc, hooks); changed {
t.Error("adding the same webhooks again changed the settings")
}
}
func TestSetupPutsItsWebhooksInTheSettingsAndTestsThemOnceInForce(t *testing.T) {
var mu sync.Mutex
body, tests := "router:\n retries: 2\n", 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
defer mu.Unlock()
w.Header().Set("Content-Type", "application/json")
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/settings":
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 1, Body: body})
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/settings":
var req adminapi.SettingsRequest
_ = json.NewDecoder(r.Body).Decode(&req)
body = req.Body
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 2, Body: body})
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/notify/test":
tests++
// The first answer comes from the control plane before it
// started again on the change: it knows no webhooks.
res := []notify.Result{}
if tests > 1 {
res = []notify.Result{{URL: notify.Redact("https://hooks.slack.com/services/T/B/c")},
{URL: notify.Redact("https://ops.example.com/in"), Err: "502 Bad Gateway"}}
}
_ = json.NewEncoder(w).Encode(res)
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "server.yaml"),
[]byte("admin_listen: "+strings.TrimPrefix(srv.URL, "http://")+"\n"), 0o600); err != nil {
t.Fatal(err)
}
var out bytes.Buffer
in := &serverInit{etcDir: dir, out: &out,
webhooks: []string{"https://hooks.slack.com/services/T/B/c", "https://ops.example.com/in"}}
err := in.ensureWebhooks(t.Context())
if err == nil || !strings.Contains(err.Error(), "502") || !strings.Contains(err.Error(), "ctl notify test") {
t.Errorf("a webhook that did not answer: %v", err)
}
mu.Lock()
defer mu.Unlock()
if !strings.Contains(body, "format: slack") || !strings.Contains(body, "url: https://ops.example.com/in") ||
!strings.Contains(body, "retries: 2") || tests < 2 {
t.Errorf("settings %q after %d tests", body, tests)
}
if strings.Contains(out.String(), "/services/T/B/c") {
t.Errorf("a webhook's secret path was printed:\n%s", out.String())
}
if !strings.Contains(out.String(), "reached https://hooks.slack.com/…") {
t.Errorf("the one that answered is not said:\n%s", out.String())
}
}
+2
View File
@@ -51,6 +51,8 @@ func (in *serverInit) ensurePatroni(ctx context.Context) (err error) {
Remote: remote,
})
}
// Kept: the standbys are made control planes on the same machines.
in.patroniNodes = setup.DB
self, err := sourceIPToward(setup.DB[0].IP)
if err != nil {
return err
+39
View File
@@ -0,0 +1,39 @@
package cli
import (
"context"
"errors"
"fmt"
"net"
)
// Three machines, each able to take over: the store's two machines are made
// control planes too, standing by while this one leads, as 'llmgw server add'
// makes any machine one. With the store replicated across them and etcd's
// three votes on the three, losing any one machine loses neither the store
// nor the gateway. With a floating address, clients keep the one address.
// ensureStandbys makes each machine of the replicated store a control plane,
// one after the other.
func (in *serverInit) ensureStandbys(ctx context.Context) error {
if len(in.patroniNodes) == 0 {
return errors.New("the standbys go on the store's machines: give them with --patroni")
}
var hosts []string
if ip, _, err := net.ParseCIDR(in.vip); err == nil {
hosts = []string{ip.String()}
}
for i, n := range in.patroniNodes {
fmt.Fprintf(in.out, " .. making %s a control plane too\n", n.Name)
sa := serverAdd{
etcDir: in.etcDir, pkiDir: in.pkiDir, hosts: hosts, vip: in.vip,
remote: n.Remote, noFirewall: in.noFirewall, addr: n.IP, ip: n.IP,
out: in.out, priority: 100 - 10*i, quiet: true,
}
if err := sa.run(ctx); err != nil {
return fmt.Errorf("%s: %w", n.Name, err)
}
in.done("%s (%s) is a control plane too, standing by while this one leads", n.Name, n.IP)
}
return nil
}
+751
View File
@@ -0,0 +1,751 @@
package cli
import (
"fmt"
"net/mail"
"net/url"
"slices"
"strconv"
"strings"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
)
// The parts of an install, each chosen and each with its settings: what the
// setup screens show before anything is installed, for one machine and for
// three alike. Beside each part is what it is for and what is missing
// without it; Enter opens its settings, which say the same at more length.
type installPart struct {
title string
// about is what the part is, at the length its dialog has room for.
about string
// fixed parts are always installed: they have settings, not a box.
fixed bool
on bool
// fields are its settings; none is a part only to tick.
fields []*setupField
// summary is a line of what it will be; off what is missing without it.
summary func() string
off string
// needs, when set, says why the part cannot be had with the rest as
// chosen: a starter model with no mirror to fetch it into.
needs func() string
}
type installForm struct {
ha bool
host string
found []string
parts []*installPart
// focus is a part, or len(parts) for the button that goes on.
focus int
// open is the part whose settings are open in a dialog.
open *installPart
dialog *setupDialog
// err is said in a dialog of its own; when errField is set, closing it
// opens that field's part with the focus on it.
err string
errPart *installPart
errField *setupField
height int
standby *installPart
vipF *setupField
control, store, mirror, node, starter, media, judge, firewall, alerts, limits, turns, bundle *installPart
admin, hosts, language, signup *setupField
storeKind *setupField
mirrorWhere, mirrorDirF, mirrorURL *setupField
starterPick, starterSource, starterCtx *setupField
judgeFail, judgeCopies, judgeBudget *setupField
judgeCats []*setupField
webhooks *setupField
rpm, tpm, keyDays *setupField
idle, safe *setupField
bundlePath, bundleSHA *setupField
}
// newInstallForm is the parts as in has them: its flags, or the defaults.
func newInstallForm(in *serverInit, host string, found []string, ha bool) *installForm {
f := &installForm{ha: ha, host: host, found: found, height: 24}
// --- the control plane ---
f.admin = textSetupField("Administrator's email", in.admin, "you@example.com",
"A password is made for this account and shown once, at the end.")
if !ha {
f.admin.Hint += " Leave it empty to make accounts later with 'llmgw users create'."
}
known := "none found"
if len(found) > 0 {
known = strings.Join(found, ", ")
}
f.hosts = textSetupField("Other names nodes reach this machine by", strings.Join(in.hosts, ", "),
"llm.example.com, 203.0.113.10",
"Already in its certificate: "+known+". Add a DNS name, a NAT address or a floating address, comma-separated.")
lang := 0
if in.language == "tr" {
lang = 1
}
f.language = choiceField("The portal speaks", []string{"English", "Türkçe"}, lang,
"To a browser that asks for neither. Each person can switch at the foot of any page.")
f.signup = choiceField("Sign-up form", []string{"closed", "open"}, map[bool]int{false: 0, true: 1}[in.signupOpen], "")
f.signup.Notes = []string{
"Accounts are made by an administrator, or come from single sign-on.",
"Anyone who reaches the portal can ask for an account; it waits for an administrator's approval.",
}
f.control = &installPart{
title: "Control plane", fixed: true, on: true,
about: "The gateway every request comes through, the admin API, the web portal on port 8080, and the " +
"certificate authority that signs every node's identity. Always installed: it is what llmgw is.\n\n" +
"Its authority's key stays in " + orText(in.pkiDir, defaultPKIDir) + " on this machine. Back it up: nothing recovers it.",
fields: []*setupField{f.admin, f.hosts, f.language, f.signup},
summary: func() string {
s := []string{}
if a := f.admin.Value(); a != "" {
s = append(s, "administrator "+a)
} else {
s = append(s, "no administrator")
}
s = append(s, f.language.Options[f.language.Choice], "sign-up "+f.signup.Options[f.signup.Choice])
return strings.Join(s, " · ")
},
}
// --- the store ---
f.storeKind = choiceField("Kept in", []string{"SQLite", "PostgreSQL"}, map[bool]int{false: 0, true: 1}[in.postgres], "")
f.storeKind.Notes = []string{
"A file on this machine. Nothing to look after; enough for one control plane and a team's traffic.",
"Installed here, with its own role and a generated password. For a larger store, or before adding a second control plane.",
}
f.store = &installPart{
title: "Store", fixed: true, on: true,
about: "Accounts, API keys, the catalog of models, usage and the cluster's settings live here.",
summary: func() string {
if ha {
return "PostgreSQL under Patroni, on the other two"
}
return f.storeKind.Options[f.storeKind.Choice] + " on this machine"
},
}
if ha {
f.store.about += "\n\nWith three machines it is PostgreSQL on the other two, replicated under Patroni: " +
"if the primary is lost the standby takes over in about thirty seconds, and the control plane follows. " +
"The machines are asked for on the next page."
} else {
f.store.fields = []*setupField{f.storeKind}
}
// --- control planes on all three ---
if ha {
f.vipF = textSetupField("Floating address", in.vip, "10.0.0.160/24",
"Optional. An unused address on these machines' network, with its prefix: keepalived keeps it on "+
"whichever control plane leads, and clients use it. Without one, put a load balancer in front, "+
"or give clients the three addresses.")
f.standby = &installPart{
title: "Control plane on all three", on: in.standbys,
about: "The two database machines become control planes too, standing by while this one leads. " +
"If the leader is lost, one of them takes over within seconds: it places models, and every node " +
"is told of it and fails over without being touched. With the store on two machines and etcd's " +
"three votes on the three, losing any one machine loses neither the store nor the gateway.\n\n" +
"The model mirror stays on this machine: while it is down, models the nodes already have keep " +
"serving, and new ones wait for it.",
fields: []*setupField{f.vipF},
off: "the gateway runs on this machine only: if it is lost, the store survives but nothing is served until it is back",
summary: func() string {
if v := f.vipF.Value(); v != "" {
return "two standbys; " + v + " follows whichever leads"
}
return "two standbys; no floating address"
},
}
}
// --- the mirror ---
where := 0
if in.mirrorURL != "" {
where = 1
}
f.mirrorWhere = choiceField("Where", []string{"on this machine", "on another machine"}, where, "")
f.mirrorWhere.Notes = []string{
"Serves model files on port 8081 to every node; its management API is on loopback only.",
"Set up there first with 'llmgw mirror init --control-plane <this machine>'.",
}
f.mirrorDirF = textSetupField("Directory", orText(in.mirrorPath, mirrorDir), mirrorDir,
"Models are large: a 7B model is about 5 GB, a 70B about 40 GB. Put it on the biggest disk.")
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.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},
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)
},
}
// --- this machine as a node ---
f.node = &installPart{
title: "This machine as a node", on: !in.noNode,
about: "Runs models here as well, on this machine's NVIDIA GPUs when it has them and on its CPU otherwise. " +
"The node installs its own engines (llama.cpp first, vLLM after where the card can run it).\n\n" +
"Leave it off when this machine has neither the memory nor the cards to spare: models then run on " +
"the nodes added later with 'llmgw node add'.",
off: "models run only on nodes added later",
summary: func() string { return "serves models here, on its GPUs or its CPU" },
}
if ha {
f.node.about += " With three machines the control plane usually serves none."
}
// --- the starter model ---
var opts, notes []string
pick := 0
cur := in.starterModelSpec()
for i, c := range starterChoices {
opts = append(opts, strings.TrimSuffix(c.Name, "-instruct"))
notes = append(notes, fmt.Sprintf("%s, about %.1f GB: %s.", c.Name, c.SizeGB, c.About))
if c.Name == cur.Name {
pick = i
}
}
opts = append(opts, "other")
notes = append(notes, "Any single GGUF file on Hugging Face.")
custom := ""
if in.starter.Source != "" && !slices.ContainsFunc(starterChoices, func(c starterSpec) bool { return c.Name == in.starter.Name }) {
pick, custom = len(starterChoices), in.starter.Source
}
f.starterPick = choiceField("Model", opts, pick, "")
f.starterPick.Notes = notes
f.starterSource = textSetupField("Its file", custom, "hf:owner/repo/file.gguf", "")
f.starterSource.Show = func() bool { return f.starterPick.Choice == len(starterChoices) }
f.starterCtx = textSetupField("Context a client may use, in tokens", strconv.Itoa(in.starterContext()), "4096",
"The engine holds four times this, for four requests at once; more context takes more memory.")
f.starter = &installPart{
title: "Starter model", on: !in.noModel,
about: "A model fetched into the mirror and served on this machine, so there is something to ask the " +
"moment the install finishes. Small ones answer on any CPU; the larger want a GPU. More models are " +
"added later from /admin/mirror or with 'llmgw ctl model add'.",
fields: []*setupField{f.starterPick, f.starterSource, f.starterCtx},
off: "nothing to ask until a model is added",
summary: func() string {
name := opts[f.starterPick.Choice]
if f.starterPick.Choice == len(starterChoices) {
name = orText(f.starterSource.Value(), "(a file)")
} else {
name = fmt.Sprintf("%s (%.1f GB)", starterChoices[f.starterPick.Choice].Name, starterChoices[f.starterPick.Choice].SizeGB)
}
return name + ", " + f.starterCtx.Value() + " tokens"
},
needs: func() string {
if !f.mirror.on {
return "the starter model is fetched into the mirror: turn the mirror on, or the starter model off"
}
return ""
},
}
if ha {
f.starter.about += " Unless this machine is a node, it runs on the first node that joins with room for it."
}
// --- pictures and 3D ---
f.media = &installPart{
title: "Pictures and 3D", on: in.media,
about: "The nodes install ComfyUI, and once a node has a card that fits one the control plane adds a " +
"picture model (FLUX.1 schnell) and a 3D model (TripoSR) by itself, so the portal's pictures and 3D " +
"pages and their APIs work without anybody writing a definition. They load when asked for and give " +
"their card back when idle.\n\n" +
"Each comes under its own licence, kept with every file it makes. A preset someone removes is not " +
"added again. Later: 'llmgw ctl model presets auto off|on'.",
off: "no pictures or 3D until a preset is added from the console or /admin",
summary: func() string { return "ComfyUI; a picture model and a 3D model once a card fits" },
}
// --- the guardrails' judge ---
j := in.judgeFor()
f.judgeFail = choiceField("When no judge answers in time", []string{"refuse", "serve flagged"},
map[string]int{"closed": 0, "open": 1}[j.Fail], "")
f.judgeFail.Notes = []string{
"The request is refused (503). Safe, and why the judge runs twice.",
"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.")
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")}
catNotes := map[string]string{
"PII": "the patterns already catch secrets and card numbers",
"Unethical Acts": "refuses much ordinary work",
"Politically Sensitive Topics": "refuses much ordinary work",
"Copyright Violation": "refuses much ordinary work",
}
for _, c := range judgeCategories {
t := toggleField(c, slices.Contains(j.Categories, c), catNotes[c])
f.judgeCats = append(f.judgeCats, t)
judgeFields = append(judgeFields, t)
}
f.judge = &installPart{
title: "Guardrails' judge", on: !in.noGuardModel,
about: "Qwen3Guard 0.6B reads every prompt and every answer for what patterns cannot see: violence, " +
"crime, sexual content, self-harm, jailbreaks. It runs on CPUs and takes no card. Without it the " +
"baseline still blocks secrets and card numbers and flags prompt injection.",
fields: judgeFields,
off: "requests are checked by patterns only: secrets, card numbers, injection",
summary: func() string {
n := 0
for _, c := range f.judgeCats {
if c.On {
n++
}
}
return fmt.Sprintf("Qwen3Guard ×%s on CPUs, %d categories, unanswered: %s",
f.judgeCopies.Value(), n, f.judgeFail.Options[f.judgeFail.Choice])
},
needs: func() string {
if !f.mirror.on {
return "the judge's model is fetched into the mirror: turn the mirror on, or the judge off"
}
return ""
},
}
// --- the firewall ---
f.firewall = &installPart{
title: "Firewall", on: !in.noFirewall,
about: "An nftables table of llmgw's own keeps its internal ports — the agents' 7443, the mirror's 8081, " +
"the store's — to the networks the cluster is on. The portal and API on 8080 stay open. " +
"Other rules on the machine are left as they are.",
off: "llmgw's internal ports answer every network this machine is on",
summary: func() string { return "internal ports to the cluster's networks only" },
}
// --- notifications ---
f.webhooks = textSetupField("Webhooks", strings.Join(in.webhooks, ", "), "https://hooks.slack.com/services/…",
"Slack, Mattermost, Rocket.Chat, or any address taking JSON; comma-separated. slack: or json: in front says which. A test is sent to each.")
f.alerts = &installPart{
title: "Notifications", on: len(in.webhooks) > 0,
about: "Where the cluster says what went wrong: a model parked because it fits nowhere, a node lost, " +
"a judge not answering, a budget spent.",
fields: []*setupField{f.webhooks},
off: "nothing is said outside the dashboards",
summary: func() string {
hooks, err := parseWebhooks([]string{f.webhooks.Value()})
if err != nil || len(hooks) == 0 {
return "(no webhook yet)"
}
var where []string
for _, h := range hooks {
where = append(where, notify.Redact(h.URL))
}
return strings.Join(where, ", ")
},
}
// --- limits ---
f.rpm = textSetupField("Requests a minute", strconv.Itoa(orInt(in.defaultRPM, defaultRPM)), "60", "")
f.tpm = textSetupField("Tokens a minute", strconv.Itoa(orInt(in.defaultTPM, defaultTPM)), "200000", "")
ttl := in.maxKeyTTL
if ttl <= 0 {
ttl = defaultMaxKeyTTL
}
f.keyDays = textSetupField("The longest a key lives, in days", strconv.Itoa(int(ttl.Hours()/24)), "90",
"A key made without an expiry gets this. Shorter per organization, team or project later.")
f.limits = &installPart{
title: "Limits and keys", fixed: true, on: true,
about: "What a key may use when nothing more particular gives it a limit: a project made without limits " +
"would otherwise be metered by nothing. Organizations, teams and projects get their own later.",
fields: []*setupField{f.rpm, f.tpm, f.keyDays},
summary: func() string {
return fmt.Sprintf("%s requests and %s tokens a minute; keys live %s days", f.rpm.Value(), f.tpm.Value(), f.keyDays.Value())
},
}
// --- on-demand turns ---
f.idle = textSetupField("Unused for", durationYAML(orDur(in.evictAfterIdle, 2*time.Minute)), "2m",
"before a model loaded on demand gives its card to another that wants it.")
f.safe = textSetupField("Safe after loading for", durationYAML(orDur(in.minLifetime, 5*time.Minute)), "5m",
"so two models do not take turns paying for loads. 1m each suits one card shared by a picture model and a 3D model.")
f.turns = &installPart{
title: "Taking turns on a card", fixed: true, on: true,
about: "Models that load when asked for share the cards: on a site with fewer cards than such models, " +
"one waits for another to go idle. These are the waits.",
fields: []*setupField{f.idle, f.safe},
summary: func() string {
return "after " + f.idle.Value() + " unused; safe for " + f.safe.Value() + " after loading"
},
}
// --- offline bundle ---
f.bundlePath = textSetupField("Bundle", in.bundle, "/media/usb/llmgw-bundle.tar",
"Made on a machine with internet by 'llmgw bundle create'.")
f.bundleSHA = textSetupField("Its sha256", in.bundleSHA, "the sha256 'llmgw bundle create' printed", "")
f.bundleSHA.Text.Width = 64
f.bundle = &installPart{
title: "Offline bundle", on: in.bundle != "",
about: "For a site with no internet: engines, releases and models carried in on a disk. They go into the " +
"mirror, and the cluster is set to reach nothing outside.",
fields: []*setupField{f.bundlePath, f.bundleSHA},
off: "engines and models are downloaded from the internet",
summary: func() string { return orText(f.bundlePath.Value(), "(a file)") },
needs: func() string {
if !f.mirror.on {
return "an offline bundle goes into the model mirror: turn the mirror on"
}
return ""
},
}
f.parts = []*installPart{f.control, f.store}
if ha {
f.parts = append(f.parts, f.standby)
}
f.parts = append(f.parts, f.mirror, f.node, f.starter, f.media, f.judge, f.firewall,
f.alerts, f.limits, f.turns, f.bundle)
return f
}
func orText(s, d string) string {
if strings.TrimSpace(s) == "" {
return d
}
return s
}
func orInt(n, d int) int {
if n <= 0 {
return d
}
return n
}
func orDur(v, d time.Duration) time.Duration {
if v <= 0 {
return d
}
return v
}
// openPart opens a part's settings.
func (f *installForm) openPart(p *installPart) {
f.open = p
f.dialog = &setupDialog{Title: p.title, About: p.about, Fields: p.fields}
f.dialog.first()
}
// fail says what is wrong, and with what, so that closing the message opens it.
func (f *installForm) fail(p *installPart, field *setupField, msg string) error {
f.err, f.errPart, f.errField = msg, p, field
return fmt.Errorf("%s", msg)
}
// key takes a key; next is whether the person asked to go on, the choices
// already put into in.
func (f *installForm) key(k tea.KeyMsg, in *serverInit) (next bool) {
if f.err != "" {
p, field := f.errPart, f.errField
f.err, f.errPart, f.errField = "", nil, nil
if p != nil {
f.openPart(p)
if field != nil {
f.dialog.focusOn(field)
}
}
return false
}
if f.dialog != nil {
if f.dialog.key(k) {
f.dialog, f.open = nil, nil
}
return false
}
n := len(f.parts) + 1
switch k.String() {
case "down", "tab", "j":
f.focus = (f.focus + 1) % n
case "up", "shift+tab", "k":
f.focus = (f.focus + n - 1) % n
case " ", "x":
if f.focus < len(f.parts) {
p := f.parts[f.focus]
if p.fixed {
break
}
p.on = !p.on
// A part that is nothing until it is told something is told
// at once.
if p.on && (p == f.alerts && f.webhooks.Value() == "" || p == f.bundle && f.bundlePath.Value() == "") {
f.openPart(p)
}
}
case "enter":
if f.focus < len(f.parts) {
f.openPart(f.parts[f.focus])
return false
}
return f.apply(in) == nil
}
return false
}
// apply checks the choices and puts them into in; what is wrong is kept to
// be said.
func (f *installForm) apply(in *serverInit) error {
for _, p := range f.parts {
if p.on && p.needs != nil {
if why := p.needs(); why != "" {
return f.fail(nil, nil, why)
}
}
}
admin := f.admin.Value()
if admin != "" || f.ha {
if _, err := mail.ParseAddress(admin); err != nil {
msg := "the administrator's email is not an address"
if admin == "" {
msg = "the administrator's email is needed"
}
return f.fail(f.control, f.admin, msg)
}
}
var hosts []string
for _, h := range strings.Split(f.hosts.Value(), ",") {
if h = strings.TrimSpace(h); h != "" {
hosts = append(hosts, h)
}
}
mirrorURL := ""
if f.mirror.on && f.mirrorWhere.Choice == 1 {
u, err := url.Parse(f.mirrorURL.Value())
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return f.fail(f.mirror, f.mirrorURL, "the other machine's mirror: its management API, as http://host:8082")
}
mirrorURL = f.mirrorURL.Value()
}
mirrorPath := ""
if f.mirror.on && f.mirrorWhere.Choice == 0 {
mirrorPath = f.mirrorDirF.Value()
if !strings.HasPrefix(mirrorPath, "/") {
return f.fail(f.mirror, f.mirrorDirF, "the mirror's directory: a path from /")
}
}
var starter starterSpec
ctx := 0
if f.starter.on {
if f.starterPick.Choice < len(starterChoices) {
starter = starterChoices[f.starterPick.Choice]
} else {
s, err := starterFrom(f.starterSource.Value())
if err != nil || !strings.HasPrefix(s.Source, "hf:") {
return f.fail(f.starter, f.starterSource, "the starter model's file: hf:<owner>/<repo>/<file>.gguf")
}
starter = s
}
n, err := strconv.Atoi(f.starterCtx.Value())
if err != nil || n < 512 || n > 262144 {
return f.fail(f.starter, f.starterCtx, "the starter's context: a number of tokens from 512 to 262144")
}
ctx = n
}
var judge judgeSpec
if f.judge.on {
judge.Fail = []string{"closed", "open"}[f.judgeFail.Choice]
n, err := strconv.Atoi(f.judgeCopies.Value())
if err != nil || n < 1 || n > 8 {
return f.fail(f.judge, f.judgeCopies, "the judge's copies: from 1 to 8")
}
judge.Replicas = n
d, err := time.ParseDuration(f.judgeBudget.Value())
if err != nil || d < 500*time.Millisecond || d > time.Minute {
return f.fail(f.judge, f.judgeBudget, "how long the judge is waited for: a duration such as 5s, at most 1m")
}
judge.Budget = d
for i, c := range f.judgeCats {
if c.On {
judge.Categories = append(judge.Categories, judgeCategories[i])
}
}
if len(judge.Categories) == 0 {
return f.fail(f.judge, f.judgeCats[0], "the judge refuses nothing: tick a category, or turn the judge off")
}
}
var hooks []string
if f.alerts.on {
h := f.webhooks.Value()
parsed, err := parseWebhooks([]string{h})
if err != nil {
return f.fail(f.alerts, f.webhooks, err.Error())
}
if len(parsed) == 0 {
return f.fail(f.alerts, f.webhooks, "give a webhook, or turn notifications off")
}
hooks = []string{h}
}
rpm, err := strconv.Atoi(f.rpm.Value())
if err != nil || rpm < 1 {
return f.fail(f.limits, f.rpm, "requests a minute: a whole number, 1 or more")
}
tpm, err := strconv.Atoi(f.tpm.Value())
if err != nil || tpm < 1 {
return f.fail(f.limits, f.tpm, "tokens a minute: a whole number, 1 or more")
}
keyDays, err := strconv.Atoi(f.keyDays.Value())
if err != nil || keyDays < 1 || keyDays > 3650 {
return f.fail(f.limits, f.keyDays, "the longest a key lives: days, from 1 to 3650")
}
idle, err := time.ParseDuration(f.idle.Value())
if err != nil || idle < 0 {
return f.fail(f.turns, f.idle, "a duration such as 2m or 90s")
}
safe, err := time.ParseDuration(f.safe.Value())
if err != nil || safe < 0 {
return f.fail(f.turns, f.safe, "a duration such as 5m or 90s")
}
bundle, sum := "", ""
if f.bundle.on {
bundle, sum = f.bundlePath.Value(), strings.ToLower(f.bundleSHA.Value())
switch {
case bundle == "":
return f.fail(f.bundle, f.bundlePath, "give the bundle's path, or turn the offline bundle off")
case sum == "":
return f.fail(f.bundle, f.bundleSHA, "give the bundle's sha256, as 'llmgw bundle create' printed it")
}
}
in.admin, in.hosts = admin, hosts
in.language = map[int]string{0: "", 1: "tr"}[f.language.Choice]
in.signupOpen = f.signup.Choice == 1
in.postgres = !f.ha && f.storeKind.Choice == 1
in.noMirror = !f.mirror.on || mirrorURL != ""
in.mirrorURL, in.mirrorPath = mirrorURL, mirrorPath
in.noNode = !f.node.on
in.noModel = !f.starter.on
in.starter, in.starterCtx = starter, ctx
in.media = f.media.on
in.noGuardModel = !f.judge.on
in.judge = judge
in.noFirewall = !f.firewall.on
in.webhooks = hooks
in.defaultRPM, in.defaultTPM = rpm, tpm
in.maxKeyTTL = time.Duration(keyDays) * 24 * time.Hour
in.evictAfterIdle, in.minLifetime = 0, 0
if idle != 2*time.Minute {
in.evictAfterIdle = idle
}
if safe != 5*time.Minute {
in.minLifetime = safe
}
in.bundle, in.bundleSHA = bundle, sum
in.standbys, in.vip = false, ""
if f.standby != nil && f.standby.on {
in.standbys, in.vip = true, f.vipF.Value()
}
if err := in.checkChoices(); err != nil {
if strings.HasPrefix(err.Error(), "floating address") {
return f.fail(f.standby, f.vipF, err.Error())
}
return f.fail(nil, nil, err.Error())
}
return nil
}
// view is the list of parts, and what the focused one is, under it.
func (f *installForm) view(width int) string {
var b strings.Builder
b.WriteString("\n " + sDim.Render("Space turns a part on or off · Enter opens its settings and says more") + "\n\n")
titleW := 0
for _, p := range f.parts {
titleW = max(titleW, len(p.title))
}
for i, p := range f.parts {
box := "[ ]"
switch {
case p.fixed:
box = " • "
case p.on:
box = "[" + sOK.Render("x") + "]"
}
title := fmt.Sprintf("%-*s", titleW, p.title)
line := ""
if p.on {
line = p.summary()
} else {
line = sDim.Render(p.off)
}
if i == f.focus {
title = fSelected.Render(title)
b.WriteString(fSelected.Render("› ") + box + " " + title + " " + truncateWidth(line, max(20, width-titleW-8)) + "\n")
} else {
b.WriteString(" " + box + " " + title + " " + truncateWidth(line, max(20, width-titleW-8)) + "\n")
}
}
b.WriteString("\n" + button(f.focus == len(f.parts), "Next") + "\n")
if f.focus < len(f.parts) {
p := f.parts[f.focus]
b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render(p.title) + "\n")
b.WriteString(paragraphs(p.about, max(40, width-6), " ") + "\n")
}
return b.String()
}
// overlay is the dialog over the list, if one is open: its title and body.
func (f *installForm) overlay() (string, string) {
if f.err != "" {
return "Not yet", f.err + "\n\n" + sDim.Render("any key to go back")
}
if f.dialog != nil {
return f.dialog.Title, f.dialog.view(max(8, f.height-8))
}
return "", ""
}
// keys is the key bar for the list or its dialog.
func (f *installForm) keys() string {
switch {
case f.err != "":
return "any key back"
case f.dialog != nil:
return setupDialogKeys(f.dialog)
}
return "↑↓ move · Space on/off · Enter settings, or on Next go on · Ctrl+C leave"
}
// planView says what will be installed and what will not, part by part, in
// words: the last look before anything is done.
func (f *installForm) planView(width int) string {
var b strings.Builder
b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render("Installed on "+f.host) + "\n\n")
var left []*installPart
for _, p := range f.parts {
if !p.on {
left = append(left, p)
continue
}
fmt.Fprintf(&b, " %s %s\n %s\n", sOK.Render("✓"), p.title, wrap(p.summary(), max(40, width-8), " "))
}
if len(left) > 0 {
b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render("Left out") + "\n\n")
for _, p := range left {
fmt.Fprintf(&b, " %s %s\n %s\n", sDim.Render("-"), p.title, sDim.Render(wrap(p.off, max(40, width-8), " ")))
}
}
return b.String()
}
+322
View File
@@ -0,0 +1,322 @@
package cli
import (
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
// A form in a dialog: text to type, one of a few choices, a thing on or off,
// and headings between them. The setup's settings for each part are one of
// these, opened over the list of parts.
type fieldKind int
const (
fkText fieldKind = iota
// fkChoice is one of Options, changed with ← →.
fkChoice
// fkToggle is on or off, changed with Space.
fkToggle
// fkHeading is a title between fields; it takes no focus.
fkHeading
)
type setupField struct {
Label string
// Hint is said under the field; Notes, for a choice, under each option
// as it is chosen.
Hint string
Notes []string
Kind fieldKind
Text textField
Options []string
Choice int
On bool
// Show, when set, says whether the field is shown, and so whether it
// counts: a directory for a mirror kept elsewhere is neither.
Show func() bool
}
func textSetupField(label, value, placeholder, hint string) *setupField {
t := newTextField()
t.SetValue(value)
t.Placeholder, t.CharLimit, t.Width = placeholder, 800, 40
return &setupField{Label: label, Hint: hint, Kind: fkText, Text: t}
}
func choiceField(label string, options []string, choice int, hint string) *setupField {
return &setupField{Label: label, Hint: hint, Kind: fkChoice, Options: options, Choice: choice}
}
func toggleField(label string, on bool, hint string) *setupField {
return &setupField{Label: label, Hint: hint, Kind: fkToggle, On: on}
}
func headingField(label string) *setupField {
return &setupField{Label: label, Kind: fkHeading}
}
func (f *setupField) shown() bool { return f.Show == nil || f.Show() }
func (f *setupField) Value() string { return strings.TrimSpace(f.Text.Value()) }
// setupDialog is a form over the screen, with what it is for above it.
type setupDialog struct {
Title string
About string
Fields []*setupField
focus int
}
// focusable is whether a field can have the focus now.
func (d *setupDialog) focusable(i int) bool {
f := d.Fields[i]
return f.Kind != fkHeading && f.shown()
}
// first puts the focus on the first field that takes it.
func (d *setupDialog) first() {
d.focus = -1
d.move(1)
}
// focusOn puts the focus on a field, as when it is what is wrong.
func (d *setupDialog) focusOn(f *setupField) {
for i, x := range d.Fields {
if x == f {
d.focus = i
}
}
d.blurAll()
}
func (d *setupDialog) move(dir int) {
n := len(d.Fields)
for range n {
d.focus = (d.focus + dir + n) % n
if d.focusable(d.focus) {
break
}
}
d.blurAll()
}
// blurAll leaves only the focused text field with a cursor.
func (d *setupDialog) blurAll() {
for i, f := range d.Fields {
if f.Kind != fkText {
continue
}
if i == d.focus {
f.Text.Focus()
} else {
f.Text.Blur()
}
}
}
// hasFields is whether there is anything to set, rather than only to read.
func (d *setupDialog) hasFields() bool {
for i := range d.Fields {
if d.focusable(i) {
return true
}
}
return false
}
// lastFocusable is the last field that takes the focus now.
func (d *setupDialog) lastFocusable() int {
for i := len(d.Fields) - 1; i >= 0; i-- {
if d.focusable(i) {
return i
}
}
return -1
}
// key takes a key and says whether the dialog is done: Esc, or Enter on
// the last field.
func (d *setupDialog) key(k tea.KeyMsg) (done bool) {
switch k.Type {
case tea.KeyEsc:
return true
case tea.KeyEnter:
if !d.hasFields() || d.focus == d.lastFocusable() {
return true
}
d.move(1)
return false
case tea.KeyTab, tea.KeyDown:
if d.hasFields() {
d.move(1)
}
return false
case tea.KeyShiftTab, tea.KeyUp:
if d.hasFields() {
d.move(-1)
}
return false
}
if d.focus < 0 || d.focus >= len(d.Fields) || !d.focusable(d.focus) {
return false
}
f := d.Fields[d.focus]
switch f.Kind {
case fkText:
f.Text, _ = f.Text.Update(k)
case fkChoice:
switch k.String() {
case "left", "h":
f.Choice = (f.Choice + len(f.Options) - 1) % len(f.Options)
case "right", "l", " ":
f.Choice = (f.Choice + 1) % len(f.Options)
}
case fkToggle:
if k.String() == " " || k.String() == "x" {
f.On = !f.On
}
}
return false
}
// dialogWidth is how wide the text in a dialog is wrapped.
const dialogWidth = 66
// view draws the form in at most maxLines lines, the part around the focus
// when it is longer: a long form scrolls as the focus moves.
func (d *setupDialog) view(maxLines int) string {
var lines []string
add := func(s string) { lines = append(lines, strings.Split(s, "\n")...) }
if d.About != "" {
add(paragraphs(d.About, dialogWidth, ""))
}
if !d.hasFields() {
add("")
add(sDim.Render("Nothing to set for this part. Esc or Enter to go back."))
return clipLines(lines, 0, 0, maxLines)
}
add("")
focusStart, focusEnd := 0, 0
for i, f := range d.Fields {
if !f.shown() {
continue
}
focused := i == d.focus
if focused {
focusStart = len(lines)
}
label := func(s string) string {
if focused {
return fSelected.Render("› " + s)
}
return " " + s
}
switch f.Kind {
case fkHeading:
add("")
add(" " + lipgloss.NewStyle().Bold(true).Render(f.Label))
continue
case fkText:
add(label(f.Label))
add(" " + f.Text.View())
case fkChoice:
var opts []string
for j, o := range f.Options {
if j == f.Choice {
opts = append(opts, "("+sOK.Render("•")+") "+o)
} else {
opts = append(opts, "( ) "+sDim.Render(o))
}
}
add(label(f.Label))
// Side by side while they fit, else one a line.
if row := strings.Join(opts, " "); ansi.StringWidth(row) <= dialogWidth-4 {
add(" " + row)
} else {
for _, o := range opts {
add(" " + o)
}
}
if f.Choice < len(f.Notes) && f.Notes[f.Choice] != "" {
add(" " + sDim.Render(wrap(f.Notes[f.Choice], dialogWidth-4, " ")))
}
case fkToggle:
box := "[ ]"
if f.On {
box = "[" + sOK.Render("x") + "]"
}
add(label(box) + " " + f.Label)
}
if f.Hint != "" {
add(" " + sDim.Render(wrap(f.Hint, dialogWidth-4, " ")))
}
if focused {
focusEnd = len(lines) - 1
}
if f.Kind != fkToggle {
add("")
}
}
for len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return clipLines(lines, focusStart, focusEnd, maxLines)
}
// clipLines is at most n of lines, keeping lines from to through in sight
// as far as they fit, with a mark at an edge that has more beyond it.
func clipLines(lines []string, from, through, n int) string {
if n <= 2 || len(lines) <= n {
return strings.Join(lines, "\n")
}
top := 0
// The last line shown is a mark when there is more below: the focus
// ends a line above it.
if through > n-2 {
top = through - (n - 2)
}
// Its first line wins over its last, under the mark above.
if from > 0 && top > from-1 {
top = from - 1
}
top = max(0, min(top, len(lines)-n))
out := append([]string{}, lines[top:top+n]...)
if top > 0 {
out[0] = sDim.Render(" ↑ more")
}
if top+n < len(lines) {
out[n-1] = sDim.Render(" ↓ more")
}
return strings.Join(out, "\n")
}
// paragraphs wraps text paragraph by paragraph, blank lines kept between.
func paragraphs(s string, width int, indent string) string {
var out []string
for _, p := range strings.Split(s, "\n\n") {
out = append(out, indent+wrap(p, width, indent))
}
return strings.Join(out, "\n\n")
}
// setupDialogKeys is the key bar under a dialog.
func setupDialogKeys(d *setupDialog) string {
if !d.hasFields() {
return "Esc or Enter back"
}
keys := "Tab/↑↓ move"
if d.focus >= 0 && d.focus < len(d.Fields) {
switch d.Fields[d.focus].Kind {
case fkChoice:
keys += " · ←→ choose"
case fkToggle:
keys += " · Space tick"
}
}
return keys + " · Esc done"
}
+60 -196
View File
@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"io"
"net/mail"
"os"
"path/filepath"
"strings"
@@ -47,33 +46,16 @@ func (w *lineWriter) Write(p []byte) (int, error) {
}
// Form fields, in order.
const (
fAdmin = iota
fHosts
fPostgres
fMirror
fNode
fModel
fGuard
fBundle
fBundleSHA
fStart
fCount
)
type initModel struct {
in *serverInit
found []string // this machine's addresses
// The form, until it is submitted.
form bool
focus int
admin textField
hosts textField
bundle textField
bundleSHA textField
toggles [fCount]bool
formError string
// The parts to install, until they are chosen and the plan agreed to.
form bool
plan bool
choices *installForm
// scroll is how far the panel is scrolled, back to the top on each page.
scroll int
// Then the run.
running bool
@@ -93,38 +75,9 @@ type initModel struct {
}
func newInitModel(in *serverInit, found []string, askForm bool) initModel {
admin := newTextField()
admin.Placeholder = "you@example.com"
admin.SetValue(in.admin)
admin.CharLimit = 200
admin.Width = 40
hosts := newTextField()
hosts.Placeholder = "llm.example.com, 203.0.113.10"
hosts.SetValue(strings.Join(in.hosts, ", "))
hosts.CharLimit = 400
hosts.Width = 40
bundle := newTextField()
bundle.Placeholder = "/media/usb/llmgw-bundle.tar"
bundle.SetValue(in.bundle)
bundle.CharLimit = 400
bundle.Width = 40
bundleSHA := newTextField()
bundleSHA.Placeholder = "the sha256 'llmgw bundle create' printed"
bundleSHA.SetValue(in.bundleSHA)
bundleSHA.CharLimit = 64
bundleSHA.Width = 64
sp := newSpinner()
m := initModel{in: in, found: found, form: askForm, admin: admin, hosts: hosts,
bundle: bundle, bundleSHA: bundleSHA, spin: sp, width: 100}
m.toggles[fPostgres] = in.postgres
m.toggles[fMirror] = !in.noMirror
m.toggles[fNode] = !in.noNode
m.toggles[fModel] = !in.noModel
m.toggles[fGuard] = !in.noGuardModel
if askForm {
m.admin.Focus()
}
return m
host, _ := os.Hostname()
return initModel{in: in, found: found, form: askForm, choices: newInstallForm(in, host, found, false),
spin: newSpinner(), width: 100, height: 30}
}
func (m initModel) Init() tea.Cmd {
@@ -140,6 +93,7 @@ func (m initModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.choices.height = msg.Height
if m.framed {
m.width = msg.Width - navWidth - 6
}
@@ -202,100 +156,32 @@ func (m *initModel) addLine(l string) {
func (m initModel) formKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
switch k.Type {
case tea.KeyTab, tea.KeyDown:
m.moveFocus(1)
case tea.KeyPgDown:
m.scroll += max(1, m.height-8)
return m, nil
case tea.KeyShiftTab, tea.KeyUp:
m.moveFocus(-1)
case tea.KeyPgUp:
m.scroll = max(0, m.scroll-max(1, m.height-8))
return m, nil
case tea.KeyEnter:
if m.focus != fStart {
m.moveFocus(1)
return m, nil
}
admin := strings.TrimSpace(m.admin.Value())
if admin != "" {
if _, err := mail.ParseAddress(admin); err != nil {
m.formError = "the administrator's email is not an address"
m.focus = fAdmin
m.admin.Focus()
return m, nil
}
}
bundle, sum := strings.TrimSpace(m.bundle.Value()), strings.ToLower(strings.TrimSpace(m.bundleSHA.Value()))
switch {
case bundle != "" && sum == "":
m.formError = "give the bundle's sha256, as 'llmgw bundle create' printed it"
m.focus = fBundleSHA
m.moveFocus(0)
return m, nil
case bundle != "" && !m.toggles[fMirror] && m.in.mirrorURL == "":
m.formError = "an offline bundle goes into the model mirror: tick it"
m.focus = fMirror
m.moveFocus(0)
return m, nil
}
m.in.bundle, m.in.bundleSHA = bundle, sum
m.in.admin = admin
m.in.hosts = nil
for _, h := range strings.Split(m.hosts.Value(), ",") {
if h = strings.TrimSpace(h); h != "" {
m.in.hosts = append(m.in.hosts, h)
}
}
m.in.postgres = m.toggles[fPostgres]
m.in.noMirror = !m.toggles[fMirror]
m.in.noNode = !m.toggles[fNode]
m.in.noModel = !m.toggles[fModel]
m.in.noGuardModel = !m.toggles[fGuard]
m.form = false
return m, tea.Batch(m.spin.Tick, func() tea.Msg { return startInitMsg{} })
}
switch m.focus {
case fAdmin:
var cmd tea.Cmd
m.admin, cmd = m.admin.Update(k)
return m, cmd
case fHosts:
var cmd tea.Cmd
m.hosts, cmd = m.hosts.Update(k)
return m, cmd
case fBundle:
var cmd tea.Cmd
m.bundle, cmd = m.bundle.Update(k)
return m, cmd
case fBundleSHA:
var cmd tea.Cmd
m.bundleSHA, cmd = m.bundleSHA.Update(k)
return m, cmd
case fPostgres, fMirror, fNode, fModel, fGuard:
if k.String() == " " || k.String() == "x" {
m.toggles[m.focus] = !m.toggles[m.focus]
if m.plan {
switch k.Type {
case tea.KeyEsc:
m.plan, m.scroll = false, 0
case tea.KeyEnter:
m.form, m.plan, m.scroll = false, false, 0
return m, tea.Batch(m.spin.Tick, func() tea.Msg { return startInitMsg{} })
}
return m, nil
}
if m.choices.key(k, m.in) {
m.plan, m.scroll = true, 0
}
return m, nil
}
func (m *initModel) moveFocus(d int) {
m.focus = (m.focus + d + fCount) % fCount
// With --mirror there is no mirror to run here, so nothing to tick.
if m.focus == fMirror && m.in.mirrorURL != "" {
m.focus = (m.focus + d + fCount) % fCount
}
m.admin.Blur()
m.hosts.Blur()
m.bundle.Blur()
m.bundleSHA.Blur()
switch m.focus {
case fAdmin:
m.admin.Focus()
case fHosts:
m.hosts.Focus()
case fBundle:
m.bundle.Focus()
case fBundleSHA:
m.bundleSHA.Focus()
}
// command is this install as a command line, for doing it again from a script.
func (in *serverInit) command() string {
return strings.Join(append([]string{"sudo llmgw server init"}, in.initFlags()...), " \\\n ")
}
func (m initModel) View() string {
@@ -306,27 +192,50 @@ func (m initModel) View() string {
if i := strings.Index(body, "\n\n"); i >= 0 {
body = body[i+1:]
}
steps := []navItem{{Label: "Start", State: "done"}, {Label: "Settings", State: "current"}, {Label: "Install", State: "todo"}}
keys := "Tab/↑↓ move · Space tick · Enter on Set up · Ctrl+C leave"
if !m.form {
steps := []navItem{{Label: "Start", State: "done"}, {Label: "Choices", State: "current"},
{Label: "Plan", State: "todo"}, {Label: "Install", State: "todo"}}
keys := m.choices.keys()
title := "What to install"
switch {
case m.form && m.plan:
steps[1].State, steps[2].State = "done", "current"
keys = "Ctrl+C stop"
keys, title = "Enter install · Esc back to the choices · PgUp/PgDn scroll · Ctrl+C leave", "Plan"
case !m.form:
steps[1].State, steps[2].State, steps[3].State = "done", "done", "current"
keys, title = "Ctrl+C stop", "Installing"
if m.done {
steps[2].State = "done"
steps[3].State = "done"
keys = "Enter close"
}
}
host, _ := os.Hostname()
return frame{Width: m.width + navWidth + 6, Height: m.height, Title: "llmgw setup", Where: host,
NavTitle: "Steps", Nav: steps, PanelTitle: "This machine as a control plane", Body: body, Keys: keys}.render()
f := frame{Width: m.width + navWidth + 6, Height: m.height, Title: "llmgw setup",
NavTitle: "Steps", Nav: steps, PanelTitle: title, Body: body, Keys: keys, Scroll: m.scroll}
if m.form && !m.plan {
f.DialogTitle, f.Dialog = m.choices.overlay()
}
f.Where, _ = os.Hostname()
return f.render()
}
// plainView is the screen as text, for a panel or a terminal that is not one.
func (m initModel) plainView() string {
var b strings.Builder
b.WriteString(sTitle.Render("llmgw · setting up this machine as a control plane") + "\n\n")
if m.form && m.plan {
b.WriteString(m.choices.planView(m.width))
b.WriteString("\n " + sDim.Render("The same from a script:") + "\n")
for _, l := range strings.Split(m.in.command(), "\n") {
b.WriteString(" " + l + "\n")
}
b.WriteString("\n" + button(true, "Enter to install") + "\n")
return b.String()
}
if m.form {
return b.String() + m.formView()
b.WriteString(m.choices.view(m.width))
if title, d := m.choices.overlay(); d != "" && !m.framed {
b.WriteString("\n" + sTitle.Render(title) + "\n" + d + "\n")
}
return b.String()
}
for _, l := range m.visibleLines() {
b.WriteString(m.lineView(l) + "\n")
@@ -386,51 +295,6 @@ func (m initModel) lineView(l string) string {
return " " + t
}
func (m initModel) formView() string {
var b strings.Builder
label := func(i int, s string) string {
if m.focus == i {
return sTitle.Render("› " + s)
}
return " " + s
}
check := func(i int, s, hint string) string {
box := "[ ]"
if m.toggles[i] {
box = "[" + sOK.Render("x") + "]"
}
return fmt.Sprintf("%s %s %s\n", label(i, box), s, sDim.Render(hint))
}
fmt.Fprintf(&b, "%s\n %s\n", label(fAdmin, "Administrator's email"), m.admin.View())
b.WriteString(" " + sDim.Render("A password is generated and shown once. Leave empty to create accounts later.") + "\n\n")
fmt.Fprintf(&b, "%s\n %s\n", label(fHosts, "Other names nodes will reach this machine by"), m.hosts.View())
found := "none found"
if len(m.found) > 0 {
found = strings.Join(m.found, ", ")
}
b.WriteString(" " + sDim.Render("Already in the certificate: "+found+". Add a DNS name or NAT address here.") + "\n\n")
b.WriteString(check(fPostgres, "PostgreSQL", "rather than SQLite; installed here, for several control planes or a larger store"))
if m.in.mirrorURL != "" {
b.WriteString(" Model mirror " + sDim.Render("at "+m.in.mirrorURL+", from --mirror") + "\n")
} else {
b.WriteString(check(fMirror, "Model mirror", "downloads model files once for every node"))
}
b.WriteString(check(fNode, "This machine as a node", "serves models here too, on its GPUs or CPU"))
b.WriteString(check(fModel, "Starter model", "a small Qwen, so there is something to ask at once"))
b.WriteString(check(fGuard, "Guardrails' judge", "Llama Guard 3 1B on a CPU, refusing violent, criminal and abusive requests"))
fmt.Fprintf(&b, "\n%s\n %s\n", label(fBundle, "Offline bundle"), m.bundle.View())
b.WriteString(" " + sDim.Render("For a site with no internet: engines, releases and models from 'llmgw bundle create'. Empty downloads them.") + "\n")
fmt.Fprintf(&b, "%s\n %s\n", label(fBundleSHA, "Its sha256"), m.bundleSHA.View())
b.WriteString("\n" + label(fStart, lipgloss.NewStyle().Bold(true).Render("Set up")) + "\n")
if m.formError != "" {
b.WriteString("\n" + sBad.Render(m.formError) + "\n")
}
if !m.framed { // the frame's key bar says it
b.WriteString("\n" + sDim.Render("tab or ↑↓ to move · space to tick · enter on Set up to start · ctrl+c to leave") + "\n")
}
return b.String()
}
// runInitTUI runs init with the screen above, asking the form first when
// the flags left the administrator unset.
func (in *serverInit) runInitTUI(ctx context.Context, out io.Writer) error {
+175 -30
View File
@@ -3,6 +3,7 @@ package cli
import (
"strings"
"testing"
"time"
tea "github.com/charmbracelet/bubbletea"
)
@@ -11,21 +12,54 @@ func typeIn(m tea.Model, s string) tea.Model {
return send(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)})
}
// toPart moves the focus in the list of parts to the one titled so.
func toPart(t *testing.T, m tea.Model, title string) tea.Model {
t.Helper()
f := m.(initModel).choices
for i := 0; i <= len(f.parts); i++ {
if f.focus < len(f.parts) && f.parts[f.focus].title == title {
return m
}
m = press(m, "down")
}
t.Fatalf("no part %q", title)
return m
}
// toNext moves the focus to the button that goes on, and presses it.
func toNext(m tea.Model) tea.Model {
f := m.(initModel).choices
for f.focus != len(f.parts) {
m = press(m, "down")
}
return press(m, "enter")
}
func TestTheInitFormBecomesInitsSettings(t *testing.T) {
in := &serverInit{pkiDir: "/etc/llmgw/pki"}
var m tea.Model = newInitModel(in, []string{"10.0.0.140"}, true)
if v := m.View(); !strings.Contains(v, "Already in the certificate: 10.0.0.140") {
m = press(toPart(t, m, "Control plane"), "enter")
m = typeIn(m, "ops@example.com")
m = press(m, "tab")
if v := m.View(); !strings.Contains(v, "Already in its certificate: 10.0.0.140") {
t.Fatalf("the addresses found are not shown:\n%s", v)
}
m = typeIn(m, "ops@example.com")
m = send(m, tea.KeyMsg{Type: tea.KeyTab})
m = typeIn(m, "llm.example.com, 203.0.113.10")
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" ")}) // PostgreSQL on
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab},
tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" ")}) // starter model off
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" ")}) // judge off
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}) // no bundle
m = send(m, tea.KeyMsg{Type: tea.KeyEnter})
m = press(m, "esc")
m = press(toPart(t, m, "Store"), "enter", "right", "esc") // PostgreSQL
// Turning notifications on asks for the webhook at once.
m = press(toPart(t, m, "Notifications"), "space")
if m.(initModel).choices.dialog == nil {
t.Fatal("notifications turned on without asking where")
}
m = typeIn(m, "https://hooks.slack.com/services/T1/B2/x, json:https://alerts.example.com/in")
m = press(m, "esc")
m = press(toPart(t, m, "Starter model"), "space") // off
m = press(toPart(t, m, "Guardrails' judge"), "space") // off
m = toNext(m)
if !m.(initModel).plan {
t.Fatalf("Next did not lead to the plan: %s", m.(initModel).choices.err)
}
if in.admin != "ops@example.com" || strings.Join(in.hosts, "|") != "llm.example.com|203.0.113.10" {
t.Errorf("admin %q hosts %q", in.admin, in.hosts)
@@ -33,8 +67,36 @@ func TestTheInitFormBecomesInitsSettings(t *testing.T) {
if !in.postgres || in.noMirror || in.noNode || !in.noModel || !in.noGuardModel {
t.Errorf("toggles: postgres %v mirror %v node %v model %v", in.postgres, !in.noMirror, !in.noNode, !in.noModel)
}
if hooks, err := parseWebhooks(in.webhooks); err != nil || len(hooks) != 2 || hooks[0].Format != "slack" || hooks[1].Format != "json" {
t.Errorf("webhooks %q: %+v %v", in.webhooks, hooks, err)
}
v := m.View()
for _, want := range []string{"✓ Store", "PostgreSQL on this machine", "Left out", "Starter model", "--postgres", "--no-model"} {
if !strings.Contains(v, want) {
t.Errorf("the plan lacks %q:\n%s", want, v)
}
}
m = press(m, "enter")
if m.(initModel).form {
t.Error("the form stayed up after Set up")
t.Error("the form stayed up after the plan was agreed to")
}
}
func TestAWebhookThatIsNoAddressKeepsTheFormUp(t *testing.T) {
in := &serverInit{}
var m tea.Model = newInitModel(in, nil, true)
m = press(toPart(t, m, "Notifications"), "space")
m = typeIn(m, "hooks.slack.com/services/x")
m = toNext(press(m, "esc"))
im := m.(initModel)
if !im.form || im.plan || !strings.Contains(m.View(), "http(s) address") {
t.Fatalf("an address without its scheme was taken:\n%s", m.View())
}
// Closing the message opens the field that is wrong.
m = press(m, "q")
d := m.(initModel).choices.dialog
if d == nil || d.Fields[d.focus] != m.(initModel).choices.webhooks {
t.Errorf("the webhook was not given the cursor")
}
}
@@ -44,34 +106,119 @@ func TestAnOfflineBundleIsGivenInTheFormWithItsDigest(t *testing.T) {
if v := m.View(); !strings.Contains(v, "Offline bundle") {
t.Fatalf("the form offers no bundle:\n%s", v)
}
for range fBundle {
m = send(m, tea.KeyMsg{Type: tea.KeyTab})
}
m = press(toPart(t, m, "Offline bundle"), "space")
m = typeIn(m, "/media/usb/b.tar")
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyEnter})
if !m.(initModel).form || !strings.Contains(m.View(), "sha256") || m.(initModel).focus != fBundleSHA {
m = toNext(press(m, "esc"))
if m.(initModel).plan || !strings.Contains(m.View(), "sha256") {
t.Fatalf("a bundle without its digest was accepted:\n%s", m.View())
}
m = press(m, "q") // the message closed, the digest has the cursor
m = typeIn(m, strings.Repeat("AB", 32))
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyEnter})
if m.(initModel).form || in.bundle != "/media/usb/b.tar" || in.bundleSHA != strings.Repeat("ab", 32) {
t.Errorf("bundle %q sha %q, form up %v", in.bundle, in.bundleSHA, m.(initModel).form)
m = toNext(press(m, "esc"))
if !m.(initModel).plan || in.bundle != "/media/usb/b.tar" || in.bundleSHA != strings.Repeat("ab", 32) {
t.Errorf("bundle %q sha %q, plan %v: %s", in.bundle, in.bundleSHA, m.(initModel).plan, m.(initModel).choices.err)
}
}
func TestAnEmailThatIsNotOneIsSaidSo(t *testing.T) {
in := &serverInit{}
var m tea.Model = newInitModel(in, nil, true)
m = press(m, "enter") // the control plane, first
m = typeIn(m, "not an address")
for range fStart {
m = send(m, tea.KeyMsg{Type: tea.KeyTab})
}
m = send(m, tea.KeyMsg{Type: tea.KeyEnter})
if !m.(initModel).form || !strings.Contains(m.View(), "not an address") {
m = toNext(press(m, "esc"))
if m.(initModel).plan || !strings.Contains(m.View(), "not an address") {
t.Errorf("a bad email was accepted:\n%s", m.View())
}
}
func TestEachPartsSettingsReachInit(t *testing.T) {
in := &serverInit{}
var m tea.Model = newInitModel(in, nil, true)
m = press(toPart(t, m, "Control plane"), "enter", "tab", "tab", "right", "tab", "right", "esc") // Türkçe, sign-up open
// The starter: qwen3-8b, the fifth, with 8192 tokens.
m = press(toPart(t, m, "Starter model"), "enter", "right", "right", "right", "right", "tab")
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
m = press(typeIn(m, "8192"), "esc")
// The judge: serving flagged, three copies, 3s, and PII too.
m = press(toPart(t, m, "Guardrails' judge"), "enter", "right", "tab")
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
m = press(typeIn(m, "3"), "tab")
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
m = typeIn(m, "3s")
for range 6 { // past the five ticked, to PII
m = press(m, "tab")
}
m = press(m, "space", "esc")
m = press(toPart(t, m, "Pictures and 3D"), "space")
m = press(toPart(t, m, "Limits and keys"), "enter")
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
m = press(typeIn(m, "120"), "tab", "tab")
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
m = press(typeIn(m, "30"), "esc")
m = press(toPart(t, m, "Taking turns on a card"), "enter")
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
m = press(typeIn(m, "1m"), "tab")
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
m = press(typeIn(m, "1m"), "esc")
m = press(toPart(t, m, "Model mirror"), "enter", "tab")
m = send(m, tea.KeyMsg{Type: tea.KeyCtrlU})
m = press(typeIn(m, "/data/models"), "esc")
m = toNext(m)
if !m.(initModel).plan {
t.Fatalf("not taken: %s", m.(initModel).choices.err)
}
switch {
case in.language != "tr" || !in.signupOpen:
t.Errorf("language %q signup %v", in.language, in.signupOpen)
case in.starter.Name != "qwen3-8b" || in.starterCtx != 8192:
t.Errorf("starter %q ctx %d", in.starter.Name, in.starterCtx)
case in.judge.Fail != "open" || in.judge.Replicas != 3 || in.judge.Budget != 3*time.Second ||
len(in.judge.Categories) != 6 || in.judge.Categories[5] != "PII":
t.Errorf("judge %+v", in.judge)
case !in.media:
t.Error("pictures and 3D not taken")
case in.defaultRPM != 120 || in.defaultTPM != defaultTPM || in.maxKeyTTL != 30*24*time.Hour:
t.Errorf("limits %d %d %s", in.defaultRPM, in.defaultTPM, in.maxKeyTTL)
case in.evictAfterIdle != time.Minute || in.minLifetime != time.Minute:
t.Errorf("turns %s %s", in.evictAfterIdle, in.minLifetime)
case in.mirrorPath != "/data/models":
t.Errorf("mirror %q", in.mirrorPath)
}
v := m.View()
for _, want := range []string{"--starter qwen3-8b", "--starter-context 8192", "--judge-fail open", "--judge-replicas 3",
"--language tr", "--media", "--mirror-dir /data/models", "--evict-after-idle 1m", "--max-key-ttl 720h"} {
if !strings.Contains(v, want) {
t.Errorf("the command lacks %q:\n%s", want, v)
}
}
}
func TestAStarterModelWantsTheMirror(t *testing.T) {
in := &serverInit{}
var m tea.Model = newInitModel(in, nil, true)
m = toNext(press(toPart(t, m, "Model mirror"), "space"))
if m.(initModel).plan || !strings.Contains(m.View(), "turn the mirror on") {
t.Errorf("a starter model with no mirror to fetch it into was taken:\n%s", m.View())
}
}
func TestALongDialogScrollsWithItsFocus(t *testing.T) {
in := &serverInit{}
m := newInitModel(in, nil, true)
m.choices.height = 24
var tm tea.Model = press(toPart(t, m, "Guardrails' judge"), "enter")
_, top := tm.(initModel).choices.overlay()
if !strings.Contains(top, "↓ more") || strings.Contains(top, "Copyright Violation") {
t.Fatalf("a dialog taller than the screen is not cut:\n%s", top)
}
for range 11 {
tm = press(tm, "tab")
}
if _, bottom := tm.(initModel).choices.overlay(); !strings.Contains(bottom, "Copyright Violation") {
t.Errorf("the focus went where it cannot be seen:\n%s", bottom)
}
}
func TestInitsLinesAreFiledAndThePasswordKeptInView(t *testing.T) {
in := &serverInit{admin: "ops@example.com", pkiDir: "/etc/llmgw/pki"}
var m tea.Model = newInitModel(in, nil, false)
@@ -111,16 +258,14 @@ func TestInitsLinesAreFiledAndThePasswordKeptInView(t *testing.T) {
}
}
func TestWithAMirrorElsewhereTheFormDoesNotOfferOne(t *testing.T) {
func TestWithAMirrorElsewhereTheFormSaysSo(t *testing.T) {
in := &serverInit{mirrorURL: "http://10.0.0.150:8082"}
var m tea.Model = newInitModel(in, nil, true)
if v := m.View(); !strings.Contains(v, "at http://10.0.0.150:8082, from --mirror") {
if v := m.View(); !strings.Contains(v, "at http://10.0.0.150:8082") {
t.Errorf("the mirror in use is not shown:\n%s", v)
}
// Tab from the addresses goes past the mirror line to PostgreSQL, then
// the node.
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab})
if f := m.(initModel).focus; f != fNode {
t.Errorf("focus %d, want the node toggle %d", f, fNode)
m = toNext(m)
if !m.(initModel).plan || in.mirrorURL != "http://10.0.0.150:8082" || !in.noMirror {
t.Errorf("mirror %q noMirror %v: %s", in.mirrorURL, in.noMirror, m.(initModel).choices.err)
}
}
+66 -115
View File
@@ -5,7 +5,6 @@ import (
"fmt"
"io"
"net"
"net/mail"
"os"
"strings"
"sync"
@@ -35,7 +34,8 @@ type setupPage int
const (
pWelcome setupPage = iota
pAdmin
// pChoices is what is installed, part by part, each with its settings.
pChoices
pMachines
// pWait shows the codes for machines joining rather than logged into,
// until they have all dialled in.
@@ -112,6 +112,10 @@ func newDBMachine(n int) dbMachine {
if os.Getenv("SSH_AUTH_SOCK") == "" {
m.auth = authKey
}
// As choosing the way to log in sets them: a key file's path is not a
// secret to hide.
m.secret.Mask = m.auth == authPassword
m.secret.Placeholder = map[int]string{authKey: "/root/.ssh/id_ed25519", authPassword: "the user's ssh password"}[m.auth]
return m
}
@@ -150,8 +154,7 @@ type setupModel struct {
ips []string
choice int // 0 one machine, 1 high availability
admin, names textField
afocus int // admin, names, next
choices *installForm
db [2]dbMachine
mfocus int // over both machines' fields, then the check button
@@ -180,12 +183,13 @@ type setupModel struct {
}
func newSetupModel(host string, ips []string) setupModel {
admin := newTextField()
admin.Placeholder, admin.CharLimit, admin.Width = "you@example.com", 200, 40
names := newTextField()
names.Placeholder, names.CharLimit, names.Width = "llm.example.com, 203.0.113.10", 400, 40
in := newDefaultServerInit()
// The control plane of a replicated setup is not where models run;
// nodes are added once it is up. Either can still be chosen.
in.noNode, in.noModel = true, true
in.standbys = true
return setupModel{
ref: &progRef{}, host: host, ips: ips, admin: admin, names: names,
ref: &progRef{}, host: host, ips: ips, in: &in, choices: newInstallForm(&in, host, ips, true),
db: [2]dbMachine{newDBMachine(1), newDBMachine(2)}, spin: newSpinner(), width: 100, height: 30,
log: &[]string{}, logMu: &sync.Mutex{},
}
@@ -204,6 +208,7 @@ func (m setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width, m.height = msg.Width, msg.Height
m.choices.height = msg.Height
m.run.width = msg.Width - navWidth - 6
case spinTickMsg:
var cmd tea.Cmd
@@ -241,6 +246,11 @@ func (m setupModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
}
func (m setupModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
// The choices' own dialogs take their keys, Esc among them.
if m.page == pChoices && (m.choices.dialog != nil || m.choices.err != "") {
m.choices.key(k, m.in)
return m, nil
}
// A dialog open takes the next key, whatever it is, to close it.
if m.formErr != "" {
m.formErr = ""
@@ -272,12 +282,13 @@ func (m setupModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.single = true
return m, tea.Quit
}
m.page = pAdmin
m.afocus = 0
m.admin.Focus()
m.page = pChoices
}
case pChoices:
if m.choices.key(k, m.in) {
m.page, m.mfocus = pMachines, 0
m.focusMachine()
}
case pAdmin:
return m.adminKey(k)
case pMachines:
return m.machinesKey(k)
case pCheck:
@@ -306,51 +317,6 @@ func (m setupModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m setupModel) adminKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
move := func(d int) {
m.afocus = (m.afocus + d + 3) % 3
m.admin.Blur()
m.names.Blur()
switch m.afocus {
case 0:
m.admin.Focus()
case 1:
m.names.Focus()
}
}
switch k.Type {
case tea.KeyTab, tea.KeyDown:
move(1)
return m, nil
case tea.KeyShiftTab, tea.KeyUp:
move(-1)
return m, nil
case tea.KeyEnter:
if m.afocus < 2 {
move(1)
return m, nil
}
if _, err := mail.ParseAddress(strings.TrimSpace(m.admin.Value())); err != nil {
m.formErr = "the administrator's email is needed, and that is not one"
m.afocus = 0
move(0)
return m, nil
}
m.formErr = ""
m.page, m.mfocus = pMachines, 0
m.db[0].addr.Focus()
return m, nil
}
var cmd tea.Cmd
switch m.afocus {
case 0:
m.admin, cmd = m.admin.Update(k)
case 1:
m.names, cmd = m.names.Update(k)
}
return m, cmd
}
func (m *setupModel) focusMachine() {
for i := range m.db {
for f := 0; f < mfCount; f++ {
@@ -509,7 +475,7 @@ func (m *setupModel) startCheck() tea.Cmd {
if d.auth == authJoin {
m.conns = append(m.conns, dbConn{remote: d.joined, addr: d.joined.Addr, joined: d.joined})
machines = append(machines, preflight.Machine{Name: d.joined.Hostname + " (" + d.joined.Addr + ", joined)",
Addr: d.joined.Addr, Roles: []preflight.Role{preflight.RoleDatabase}, Remote: d.joined})
Addr: d.joined.Addr, Roles: m.dbRoles(), Remote: d.joined})
continue
}
g := &onboard.GoSSH{
@@ -524,7 +490,7 @@ func (m *setupModel) startCheck() tea.Cmd {
}
m.conns = append(m.conns, dbConn{remote: g, addr: g.Addr, ssh: g})
machines = append(machines, preflight.Machine{Name: g.Addr, Addr: g.Addr,
Roles: []preflight.Role{preflight.RoleDatabase}, Remote: g})
Roles: m.dbRoles(), Remote: g})
}
m.checking = true
m.report = preflight.Report{}
@@ -555,6 +521,15 @@ func (m *setupModel) startCheck() tea.Cmd {
}
}
// dbRoles are what the other two machines are to be: the store, and a
// control plane too when they stand by.
func (m setupModel) dbRoles() []preflight.Role {
if m.in.standbys {
return []preflight.Role{preflight.RoleDatabase, preflight.RoleControl}
}
return []preflight.Role{preflight.RoleDatabase}
}
// targets are the database machines as user@address, for the command line.
func (m setupModel) targets() []string {
var out []string
@@ -568,43 +543,20 @@ func (m setupModel) targets() []string {
return out
}
func (m setupModel) namesList() []string {
var out []string
for _, h := range strings.Split(m.names.Value(), ",") {
if h = strings.TrimSpace(h); h != "" {
out = append(out, h)
}
}
return out
}
// command is what this setup is on the command line, for doing it again
// from a script — with keys in place, since a password cannot be given there.
func (m setupModel) command() string {
parts := []string{"sudo --preserve-env=SSH_AUTH_SOCK llmgw server init",
"--admin " + strings.TrimSpace(m.admin.Value())}
for _, h := range m.namesList() {
parts = append(parts, "--host "+h)
}
for _, t := range m.targets() {
parts = append(parts, "--patroni "+t)
}
parts = append(parts, "--no-node", "--no-model")
return strings.Join(parts, " \\\n ")
in := *m.in
in.patroni = m.targets()
return strings.Join(append([]string{"sudo --preserve-env=SSH_AUTH_SOCK llmgw server init"}, in.initFlags()...), " \\\n ")
}
// startRun installs: sudo granted where it asks for a password, the control
// plane here, the store on the other two, then sudo taken back.
func (m *setupModel) startRun() tea.Cmd {
in := newDefaultServerInit()
in.admin = strings.TrimSpace(m.admin.Value())
in.hosts = m.namesList()
in := m.in
in.patroni = m.targets()
// The control plane of a replicated setup is not where models run;
// nodes are added once it is up.
in.noNode, in.noModel = true, true
m.in = &in
m.run = newInitModel(&in, nil, false)
m.run = newInitModel(in, nil, false)
m.run.running, m.run.width, m.run.spin = true, m.width, m.spin
ref, conns, logp, mu, listener := m.ref, m.conns, m.log, m.logMu, m.listener
return func() tea.Msg {
@@ -659,7 +611,7 @@ var setupSteps = []struct {
label string
page setupPage
}{
{"Start", pWelcome}, {"Administrator", pAdmin}, {"Machines", pMachines},
{"Start", pWelcome}, {"Choices", pChoices}, {"Machines", pMachines},
{"Checks", pCheck}, {"Plan", pPlan}, {"Install", pRun},
}
@@ -692,6 +644,9 @@ func (m setupModel) View() string {
if m.formErr != "" {
f.DialogTitle, f.Dialog = "Not yet", m.formErr+"\n\n"+sDim.Render("any key to go back")
}
if m.page == pChoices {
f.DialogTitle, f.Dialog = m.choices.overlay()
}
return f.render()
}
@@ -700,8 +655,12 @@ func (m setupModel) pageView() (string, string, string) {
switch m.page {
case pWelcome:
return "Start", m.welcomeView(), "↑↓ choose · Enter go on · Ctrl+C leave"
case pAdmin:
return "Administrator", m.adminView(), "Tab/↑↓ move · Enter next · Esc back · Ctrl+C leave"
case pChoices:
keys := m.choices.keys()
if m.choices.dialog == nil && m.choices.err == "" {
keys = strings.Replace(keys, " · Ctrl+C", " · Esc back · Ctrl+C", 1)
}
return "What to install", m.choices.view(m.width - navWidth - 6), keys
case pMachines:
return "Database machines", m.machinesView(), "Tab/↑↓ move · ←→ how to log in · Enter next · Esc back · PgUp/PgDn scroll"
case pWait:
@@ -742,24 +701,8 @@ func (m setupModel) welcomeView() string {
}
opt(0, "One machine", "This machine runs everything: control plane, model mirror,\n and models if it has room.")
opt(1, "High availability — three machines",
"This one is the control plane; two more keep the store, replicated,\n so losing any one machine loses nothing.")
return b.String()
}
func (m setupModel) adminView() string {
var b strings.Builder
label := func(i int, s string) string {
if m.afocus == i {
return fSelected.Render("› " + s)
}
return " " + s
}
b.WriteString("\n")
fmt.Fprintf(&b, "%s\n %s\n %s\n\n", label(0, "Administrator's email"), m.admin.View(),
sDim.Render("A password is generated and shown once, at the end."))
fmt.Fprintf(&b, "%s\n %s\n %s\n\n", label(1, "Other names nodes will reach this machine by"), m.names.View(),
sDim.Render("Known: "+strings.Join(append([]string{m.host}, m.ips...), ", ")+". Add a DNS name or a floating address."))
b.WriteString(button(m.afocus == 2, "Next") + "\n")
"Each of the three can run the gateway; two keep the store, replicated,\n"+
" and etcd votes on all three, so losing any one machine loses nothing.")
return b.String()
}
@@ -876,17 +819,25 @@ func (m setupModel) checkView() string {
func (m setupModel) planView() string {
var b strings.Builder
b.WriteString("\n What will be installed:\n\n")
fmt.Fprintf(&b, " %s %s\n %s\n", lipgloss.NewStyle().Bold(true).Render(m.host), sDim.Render(m.thisAddr()),
"control plane and gateway, model mirror, etcd (the third vote)")
fmt.Fprintf(&b, " administrator %s\n", strings.TrimSpace(m.admin.Value()))
b.WriteString(m.choices.planView(m.width - navWidth - 6))
b.WriteString("\n " + lipgloss.NewStyle().Bold(true).Render("The three machines") + "\n")
here := "control plane, leading at first; model mirror; etcd"
there := "PostgreSQL under Patroni, etcd"
if m.in.standbys {
there = "control plane, standing by; " + there
}
if m.in.vip != "" {
here += "; keepalived"
there += "; keepalived"
}
fmt.Fprintf(&b, " %s %s\n %s\n", lipgloss.NewStyle().Bold(true).Render(m.host), sDim.Render(m.thisAddr()), here)
granted := false
for _, d := range m.db {
addr := strings.TrimSpace(d.addr.Value())
if d.joined != nil {
addr = d.joined.Hostname + " " + sDim.Render(d.joined.Addr+", joined")
}
fmt.Fprintf(&b, " %s\n PostgreSQL under Patroni, etcd\n", lipgloss.NewStyle().Bold(true).Render(addr))
fmt.Fprintf(&b, " %s\n %s\n", lipgloss.NewStyle().Bold(true).Render(addr), there)
granted = granted || d.sudo.Value() != ""
}
b.WriteString("\n The store is replicated from the first to the second; if the primary is lost\n" +
+23 -9
View File
@@ -26,6 +26,12 @@ func press(m tea.Model, keys ...string) tea.Model {
msg = tea.KeyMsg{Type: tea.KeyTab}
case "down":
msg = tea.KeyMsg{Type: tea.KeyDown}
case "up":
msg = tea.KeyMsg{Type: tea.KeyUp}
case "space":
msg = tea.KeyMsg{Type: tea.KeySpace, Runes: []rune(" ")}
case "left":
msg = tea.KeyMsg{Type: tea.KeyLeft}
case "esc":
msg = tea.KeyMsg{Type: tea.KeyEsc}
case "right":
@@ -41,20 +47,28 @@ func press(m tea.Model, keys ...string) tea.Model {
func TestTheSetupAsksForWhatHighAvailabilityNeedsAndRefusesWhatWillNotDo(t *testing.T) {
m := tea.Model(newSetupModel("ctl", []string{"10.0.0.5"}))
m = press(m, "down", "enter")
if s := m.(setupModel); s.page != pAdmin {
t.Fatalf("high availability did not lead to the administrator: page %d", s.page)
if s := m.(setupModel); s.page != pChoices {
t.Fatalf("high availability did not lead to the choices: page %d", s.page)
}
// Three machines, each a control plane, unless told otherwise.
if v := m.View(); !strings.Contains(v, "Control plane on all three") || !strings.Contains(v, "two standbys") {
t.Errorf("the standbys are not offered, ticked:\n%s", v)
}
// No email: refused, with a reason.
m = press(m, "tab", "tab", "enter")
if s := m.(setupModel); s.page != pAdmin || !strings.Contains(s.formErr, "email") {
t.Fatalf("went on without an administrator: %+v", s.formErr)
m = press(m, "up", "enter")
if s := m.(setupModel); s.page != pChoices || !strings.Contains(s.choices.err, "email") {
t.Fatalf("went on without an administrator: %+v", s.choices.err)
}
// The refusal is a dialog; a key closes it, and the cursor is back on
// the email.
// The refusal is a dialog; a key closes it, and the control plane's
// settings open with the cursor on the email.
if !strings.Contains(m.View(), "Not yet") {
t.Error("the refusal was not shown as a dialog")
}
m = press(m, "x", "a@example.com", "tab", "tab", "enter")
m = press(m, "q")
if s := m.(setupModel); s.choices.dialog == nil || s.choices.dialog.Fields[s.choices.dialog.focus] != s.choices.admin {
t.Fatal("the email was not given the cursor")
}
m = press(m, "a@example.com", "esc", "enter")
s := m.(setupModel)
if s.page != pMachines {
t.Fatalf("page %d: %s", s.page, s.formErr)
@@ -78,7 +92,7 @@ func TestTheSetupAsksForWhatHighAvailabilityNeedsAndRefusesWhatWillNotDo(t *test
t.Errorf("a valid pair refused: %s", err)
}
cmd := s.command()
for _, want := range []string{"--admin a@example.com", "--patroni ubuntu@10.0.0.6", "--patroni ubuntu@10.0.0.7", "--no-node"} {
for _, want := range []string{"--admin a@example.com", "--patroni ubuntu@10.0.0.6", "--patroni ubuntu@10.0.0.7", "--no-node", "--standbys"} {
if !strings.Contains(cmd, want) {
t.Errorf("command lacks %q:\n%s", want, cmd)
}
+1 -1
View File
@@ -107,7 +107,7 @@ var Routes = []Route{
{Method: "GET", Path: "/v1/memory/{key}", Summary: "A note", Response: MemoryNote{}},
{Method: "GET", Path: "/v1/memory", Summary: "The project's notes", Response: MemoryList{}},
{Method: "DELETE", Path: "/v1/memory/{key}", Summary: "Forget a note"},
{Method: "POST", Path: "/mcp", Summary: "The gateway's MCP server: the memory notes as tools",
{Method: "POST", Path: "/mcp", Summary: "The gateway's MCP server: the memory notes, and pictures, 3D models, vision and workflows, as tools",
Request: MCPRequest{}, Response: anyJSON},
{Method: "GET", Path: "/me", Summary: "The caller: its key, its place in the tree, its limits and what it has used",
Response: Me{}},
+132 -5
View File
@@ -20,6 +20,7 @@ import (
"os/signal"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"syscall"
@@ -112,7 +113,15 @@ func RunGate(ctx context.Context, cfg GateConfig) error {
return err
}
models := filepath.Join(state, "models")
if err := LinkWeights(cfg.Weights, models); err != nil {
shared, err := parseShared(os.Getenv(SharedModelsEnv))
if err != nil {
return err
}
all := []string{cfg.Weights}
for _, m := range shared {
all = append(all, m.Path)
}
if err := LinkAllWeights(all, models); err != nil {
return err
}
if repo := os.Getenv(HFRepoEnv); repo != "" {
@@ -120,6 +129,20 @@ func RunGate(ctx context.Context, cfg GateConfig) error {
return fmt.Errorf("showing the weights as %s: %w", repo, err)
}
}
for _, m := range shared {
if m.HFRepo == "" {
continue
}
if err := LinkHFCache(m.Path, m.HFRepo, hfHubDir()); err != nil {
return fmt.Errorf("showing %s's weights as %s: %w", m.Name, m.HFRepo, err)
}
}
names := []string{cfg.Model}
for _, m := range shared {
if !slices.Contains(names, m.Name) {
names = append(names, m.Name)
}
}
extra := filepath.Join(state, "extra_model_paths.yaml")
if err := os.WriteFile(extra, []byte(extraModelPaths(models)), 0o644); err != nil { //nolint:gosec // not secret
return err
@@ -177,7 +200,7 @@ func RunGate(ctx context.Context, cfg GateConfig) error {
go book.watch(ctx, upstream, clientID, log)
srv := &http.Server{
Addr: cfg.Listen,
Handler: newGate(upstream, cfg.Key, cfg.Model, book, clientID),
Handler: newGate(upstream, cfg.Key, names, book, clientID),
ReadHeaderTimeout: 10 * time.Second,
}
served := make(chan error, 1)
@@ -218,12 +241,12 @@ func shutdown(srv *http.Server) {
// NewGate is the gate's handler, in front of the ComfyUI at upstream.
func NewGate(upstream *url.URL, key, model string) http.Handler {
return newGate(upstream, key, model, newProgressBook(), "")
return newGate(upstream, key, []string{model}, newProgressBook(), "")
}
// newGate is the gate with the book of its workflows' progress, kept under
// clientID; an empty one leaves the workflows' own client ids alone.
func newGate(upstream *url.URL, key, model string, book *progressBook, clientID string) http.Handler {
func newGate(upstream *url.URL, key string, names []string, book *progressBook, clientID string) http.Handler {
proxy := httputil.NewSingleHostReverseProxy(upstream)
base := proxy.Director
proxy.Director = func(r *http.Request) {
@@ -269,7 +292,11 @@ func newGate(upstream *url.URL, key, model string, book *progressBook, clientID
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"object":"list","data":[{"id":%q,"object":"model","owned_by":"comfyui"}]}`, model)
data := make([]map[string]string, 0, len(names))
for _, n := range names {
data = append(data, map[string]string{"id": n, "object": "model", "owned_by": "comfyui"})
}
_ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
return
}
if id, ok := strings.CutPrefix(r.URL.Path, "/progress/"); ok && r.Method == http.MethodGet {
@@ -306,6 +333,106 @@ func FileName(path string) string {
return cachedName.ReplaceAllString(filepath.Base(path), "")
}
// SharedModelsEnv is where the agent hands a shared replica's gate its
// members: name, the path their weights were fetched to, and the repository
// a node loads them by name from.
const SharedModelsEnv = "LLMGW_COMFYUI_MODELS"
// sharedModel is one member of a shared replica.
type sharedModel struct {
Name string `json:"name"`
Path string `json:"path"`
HFRepo string `json:"hf_repo"`
}
func parseShared(raw string) ([]sharedModel, error) {
if strings.TrimSpace(raw) == "" {
return nil, nil
}
var ms []sharedModel
if err := json.Unmarshal([]byte(raw), &ms); err != nil {
return nil, fmt.Errorf("%s: %w", SharedModelsEnv, err)
}
for _, m := range ms {
if m.Name == "" || m.Path == "" {
return nil, fmt.Errorf("%s: a member with no name or no fetched path", SharedModelsEnv)
}
}
return ms, nil
}
// LinkAllWeights shows ComfyUI every model's weights at once, for a replica
// that answers for several: links made before are taken away first, then
// each file of each is linked by its name. Two weights files of one name
// are refused — ComfyUI would load one for both; anything else of one name
// (a README, a licence) the first keeps.
func LinkAllWeights(weights []string, dir string) error {
if len(weights) == 1 {
return LinkWeights(weights[0], dir)
}
if err := LinkWeights(weights[0], dir); err != nil {
return err
}
for _, w := range weights[1:] {
files, err := weightFiles(w)
if err != nil {
return err
}
for _, f := range files {
link := filepath.Join(dir, FileName(f))
if cur, err := os.Readlink(link); err == nil {
if cur == f {
continue
}
if isWeightsFile(f) {
return fmt.Errorf("two models' weights are both named %s (%s and %s): give one comfyui.checkpoint of another name",
FileName(f), cur, f)
}
continue
}
if err := os.Symlink(f, link); err != nil {
return err
}
}
}
return nil
}
// weightFiles are the files a weights source is: itself, or each of its
// directory's.
func weightFiles(weights string) ([]string, error) {
weights, err := filepath.Abs(weights)
if err != nil {
return nil, err
}
info, err := os.Stat(weights)
if err != nil {
return nil, fmt.Errorf("the weights: %w", err)
}
if !info.IsDir() {
return []string{weights}, nil
}
entries, err := os.ReadDir(weights)
if err != nil {
return nil, err
}
var out []string
for _, e := range entries {
if e.Type().IsRegular() && !strings.HasPrefix(e.Name(), ".") {
out = append(out, filepath.Join(weights, e.Name()))
}
}
return out, nil
}
func isWeightsFile(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".sft":
return true
}
return false
}
// LinkWeights shows ComfyUI the weights: a link in dir to the file, or to
// every file of the directory. Links made before are taken away first, so
// a replica started again on other weights sees only those.
+38
View File
@@ -261,3 +261,41 @@ func TestLLMGWsOwnNodesArePutInEveryReplica(t *testing.T) {
t.Fatalf("%v %.80s", err, b)
}
}
func TestASharedReplicaSeesEveryMembersWeights(t *testing.T) {
root := t.TempDir()
write := func(p string) string {
full := filepath.Join(root, p)
_ = os.MkdirAll(filepath.Dir(full), 0o755)
if err := os.WriteFile(full, []byte("w"), 0o644); err != nil {
t.Fatal(err)
}
return full
}
flux := write("flux1-schnell-fp8.safetensors")
write("sf3d/model.safetensors")
write("sf3d/README.md")
write("tripo/model.ckpt")
write("tripo/README.md")
dir := filepath.Join(root, "models")
if err := LinkAllWeights([]string{flux, filepath.Join(root, "sf3d"), filepath.Join(root, "tripo")}, dir); err != nil {
t.Fatal(err)
}
for _, want := range []string{"flux1-schnell-fp8.safetensors", "model.safetensors", "model.ckpt", "README.md"} {
if _, err := os.Lstat(filepath.Join(dir, want)); err != nil {
t.Errorf("%s not linked: %v", want, err)
}
}
// Two weights of one name: ComfyUI would load one for both.
write("other/model.safetensors")
err := LinkAllWeights([]string{filepath.Join(root, "sf3d"), filepath.Join(root, "other")}, dir)
if err == nil || !strings.Contains(err.Error(), "both named model.safetensors") {
t.Errorf("err %v", err)
}
if ms, err := parseShared(`[{"name":"sf3d","path":"/x","hf_repo":"stabilityai/stable-fast-3d"}]`); err != nil || ms[0].HFRepo == "" {
t.Errorf("parse %+v %v", ms, err)
}
if _, err := parseShared(`[{"name":"sf3d"}]`); err == nil {
t.Error("a member with no path was taken")
}
}
+1 -1
View File
@@ -60,7 +60,7 @@ func TestTheGateHearsTheWorkflowsItLetsThrough(t *testing.T) {
defer comfy.Close()
u, _ := url.Parse(comfy.URL)
book := newProgressBook()
gate := httptest.NewServer(newGate(u, "", "m", book, "llmgw-gate-1"))
gate := httptest.NewServer(newGate(u, "", []string{"m"}, book, "llmgw-gate-1"))
defer gate.Close()
resp, err := http.Post(gate.URL+"/prompt", "application/json", strings.NewReader(`{"prompt":{},"client_id":"router"}`))
if err != nil {
+5 -1
View File
@@ -161,6 +161,10 @@ type Server struct {
// Presets is how the models that come with llmgw reach the catalog.
Presets PresetSettings `koanf:"presets"`
// OnDemand is how models that load when asked for take turns on the
// cards.
OnDemand OnDemandSettings `koanf:"on_demand"`
// Agents are the nodes' agent settings: agent.yaml's keys but those an
// agent needs to start, for every node (default) and for one
// (nodes.<id>). Sent to the agents as they change; an agent whose
@@ -546,7 +550,7 @@ type Control struct {
// Only meaningful with a shared store, where a second control plane can
// see the claim. Long enough to survive a slow query or a brief network
// stall, short enough that a standby takes over before anyone has finished
// reading the alert. Zero takes the default.
// reading the alert. Zero takes the default, 10s.
LeaseTTL time.Duration `koanf:"lease_ttl"`
}
+18
View File
@@ -0,0 +1,18 @@
package config
import "time"
// OnDemandSettings is how models that load when asked for take turns on the
// cards: on a site with fewer cards than such models, the wait between one
// and the next.
type OnDemandSettings struct {
// EvictAfterIdle is how long an on-demand replica must go unasked-for
// before a model that wants its card may have it. Zero keeps 2m.
EvictAfterIdle time.Duration `koanf:"evict_after_idle"`
// MinLifetime is how long a replica that has just come up is safe from
// that, so two models do not take turns paying for loads. Zero keeps
// 5m. On one card shared by a picture model and a 3D model, 1m each
// brings the wait between a picture and its mesh to about a minute.
// Both are in force on the leader within seconds.
MinLifetime time.Duration `koanf:"min_lifetime"`
}
+13
View File
@@ -45,6 +45,19 @@ func (a *API) nodeExporterTargetsWith(r *http.Request, nodes []NodeView) []SDGro
name, _ := os.Hostname()
add(host, name, "control-plane")
}
// Every control plane, not this one only, before the store's members:
// Prometheus asks each of them, and a machine that one called a control
// plane and another a store member was two targets, its every series
// counted twice.
if a.controlPlanes != nil {
for _, cp := range a.controlPlanes().Members {
host := cp.Address
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
add(strings.TrimSpace(host), cp.Name, "control-plane")
}
}
for _, n := range nodes {
host := n.MgmtAddr
if h, _, err := net.SplitHostPort(host); err == nil {
+26
View File
@@ -21,3 +21,29 @@ func TestNodeExporterTargetsAreTheControlPlaneAndTheStore(t *testing.T) {
}
}
}
func TestEveryControlPlaneListsTheOthersAlike(t *testing.T) {
// 140 called 150 a store member and 150 called itself a control plane:
// Prometheus, asking both, scraped 150 twice and counted it twice.
targets := func(self string) map[string]string {
a := &API{}
a.SetStoreStatus(func() ([]StoreMember, error) {
return []StoreMember{{Name: "llmgw-pg01", Host: "10.0.0.149"}, {Name: "llmgw-ctl02", Host: "10.0.0.150"}}, nil
})
a.SetControlPlanes(func() ControlPlanes {
return ControlPlanes{Members: []ControlPlane{{Name: "llmgw-ctl", Address: "10.0.0.140:7443"},
{Name: "llmgw-ctl02", Address: "10.0.0.150:7443"}}}
})
r := httptest.NewRequest("GET", "http://"+self+":9091/prometheus/targets", nil)
out := map[string]string{}
for _, g := range a.nodeExporterTargetsWith(r, nil) {
out[g.Targets[0]] = g.Labels["role"]
}
return out
}
from140, from150 := targets("10.0.0.140"), targets("10.0.0.150")
if from140["10.0.0.150:9100"] != "control-plane" || from150["10.0.0.140:9100"] != "control-plane" ||
from140["10.0.0.149:9100"] != "store" {
t.Errorf("from 140 %v, from 150 %v", from140, from150)
}
}
+48 -1
View File
@@ -409,6 +409,10 @@ type Model struct {
// say that the repository has published different weights since, and
// move the model to them.
Origin *Origin `json:"origin,omitempty" koanf:"origin"`
// Shared is set on an environment's hidden entry only: the members its
// replica answers for. Made by the catalog, never written.
Shared *SharedComfy `json:"shared,omitempty" koanf:"-"`
}
// RoleGuard is a judge model's role.
@@ -650,6 +654,11 @@ const NodesEnv = "LLMGW_COMFYUI_NODES"
// HFRepoEnv carries the repository a node loads the weights by name from.
const HFRepoEnv = "LLMGW_COMFYUI_HF_REPO"
// SharedModelsEnv carries a shared replica's members — name, weights source,
// repository — to the agent, which fetches each and hands the gate where
// they landed.
const SharedModelsEnv = "LLMGW_COMFYUI_MODELS"
var hfRepo = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
var (
@@ -778,6 +787,9 @@ func New(models []Model) (*Catalog, error) {
}
for i := range models {
if models[i].IsEnvironment() {
continue // the catalog's own, made again below
}
m := models[i].normalize()
if err := m.validate(); err != nil {
return nil, err
@@ -851,9 +863,25 @@ func New(models []Model) (*Catalog, error) {
}
canaryOf[target.Name] = name
}
if _, err := c.environments(); err != nil {
envs, err := c.environments()
if err != nil {
return nil, err
}
for _, e := range envs {
if len(e.Models) < 2 {
continue
}
var members []Model
for _, n := range e.Models {
members = append(members, c.models[n])
}
em := envModel(e, members)
if _, clash := c.models[em.Name]; clash {
return nil, fmt.Errorf("model %q: the name is the hidden entry of environment %q", em.Name, e.Name)
}
c.models[em.Name] = em
c.order = append(c.order, em.Name)
}
return c, nil
}
@@ -1297,6 +1325,25 @@ func (m Model) EngineProfile(name string) (engines.Profile, bool) {
}
env[NodesEnv] = string(nodes)
}
if p.Engine == EngineComfyUI && m.Shared != nil {
// Every member's weights, for the node to fetch and the gate to
// link: one replica answers for all of them.
type member struct {
Name string `json:"name"`
Source string `json:"source"`
HFRepo string `json:"hf_repo,omitempty"`
}
var ms []member
for _, sm := range m.Shared.Members {
ms = append(ms, member{Name: sm.Name, Source: sm.Source, HFRepo: sm.HFRepo})
}
b, _ := json.Marshal(ms)
env = maps.Clone(env)
if env == nil {
env = map[string]string{}
}
env[SharedModelsEnv] = string(b)
}
if p.Engine == EngineComfyUI && m.ComfyUI != nil && m.ComfyUI.HuggingFaceRepo != "" {
env = maps.Clone(env)
if env == nil {
+1 -1
View File
@@ -75,7 +75,7 @@ func (c *Catalog) environments() ([]Environment, error) {
for _, name := range c.order {
m := c.models[name]
env := m.ComfyEnvironment()
if env == "" {
if env == "" || m.IsEnvironment() {
continue
}
if !envName.MatchString(env) {
+110
View File
@@ -0,0 +1,110 @@
package catalog
import (
"time"
)
// A shared replica: one ComfyUI serving every model of an environment.
//
// Each ComfyUI model ran in a replica of its own, so on one card a picture
// model and a 3D model took turns, each stopping the other and loading
// ComfyUI and its weights from nothing. An environment of two or more models
// gets a hidden catalog entry of its own, whose replica carries every
// member's weights and custom nodes and answers for each of them; the
// members get no replicas of their own. Placement, idleness, eviction and
// adoption then work as for any model, on that entry.
// RoleEnvironment is the role of an environment's hidden entry.
const RoleEnvironment = "environment"
// EnvModelPrefix names an environment's hidden entry: comfyui-env-<name>.
const EnvModelPrefix = "comfyui-env-"
// SharedComfy is what an environment's entry carries of its members.
type SharedComfy struct {
Environment string `json:"environment"`
Members []SharedMember `json:"members"`
}
// SharedMember is one model a shared replica answers for.
type SharedMember struct {
Name string `json:"name"`
Aliases []string `json:"aliases,omitempty"`
// Source is the member's weights, which the replica's node fetches.
Source string `json:"source"`
// HFRepo is the repository its node loads the weights by name from.
HFRepo string `json:"hf_repo,omitempty"`
// Workflow and Checkpoint are what a request for it is sent.
Workflow string `json:"workflow"`
Checkpoint string `json:"checkpoint"`
}
// IsEnvironment reports whether a model is an environment's hidden entry.
func (m Model) IsEnvironment() bool { return m.Role == RoleEnvironment }
// SharedBy is the hidden entry whose replica serves a model, when it is in
// an environment of two or more.
func (c *Catalog) SharedBy(model string) (string, bool) {
m, ok := c.Resolve(model)
if !ok || m.IsEnvironment() || m.ComfyEnvironment() == "" {
return "", false
}
name := EnvModelPrefix + m.ComfyEnvironment()
if _, ok := c.models[name]; !ok {
return "", false
}
return name, true
}
// envModel is the hidden entry for an environment of two or more models: on
// the first member's profiles, needing the most memory any of them does,
// on demand for the longest idle timeout of theirs.
func envModel(env Environment, members []Model) Model {
first := members[0]
profs := make([]Profile, len(first.Profiles))
copy(profs, first.Profiles)
mem := 0
for _, m := range members {
for _, p := range m.Profiles {
mem = max(mem, p.MinMemoryGB)
}
}
shared := &SharedComfy{Environment: env.Name}
var idle time.Duration
minN, maxN := 0, 1
class := ""
for _, m := range members {
workflow, ckpt := m.ComfyWorkflow()
hf := ""
if m.ComfyUI != nil {
hf = m.ComfyUI.HuggingFaceRepo
}
shared.Members = append(shared.Members, SharedMember{Name: m.Name, Aliases: m.Aliases,
Source: m.Source, HFRepo: hf, Workflow: workflow, Checkpoint: ckpt})
idle = max(idle, m.Scaling.IdleTimeout)
minN, maxN = max(minN, m.Scaling.Min), max(maxN, m.Scaling.Max)
if ClassRank(m.EffectiveClass()) > ClassRank(class) || class == "" {
class = m.EffectiveClass()
}
}
for i := range profs {
profs[i].MinMemoryGB = max(profs[i].MinMemoryGB, mem)
profs[i].ComfyUI = nil // the environment's nodes, below
profs[i].Source = ""
}
return Model{
Name: EnvModelPrefix + env.Name,
Role: RoleEnvironment,
Description: "the shared ComfyUI replica of environment " + env.Name,
Source: first.Source,
ComfyUI: &ComfyUI{Environment: env.Name, Nodes: env.Nodes, Output: OutputEnvironment},
Profiles: profs,
Scaling: Scaling{Min: minN, Max: maxN, IdleTimeout: idle},
Class: class,
Shared: shared,
}
}
// OutputEnvironment is an environment entry's output: it makes nothing of
// its own, its members do.
const OutputEnvironment = "environment"
+51
View File
@@ -0,0 +1,51 @@
package catalog
import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestAnEnvironmentOfTwoGetsOneHiddenEntryThatServesBoth(t *testing.T) {
// flux and a mesh model on one card took turns, each loading ComfyUI
// from nothing; in one environment one replica answers for both.
flux := comfyModel("/w/flux.safetensors", &ComfyUI{Environment: "art", Workflow: "flux-schnell"})
flux.Scaling = Scaling{Max: 1, IdleTimeout: 15 * time.Minute}
sf3d := inEnv("sf3d", "art", "/w/sf3d/", CustomNode{Repo: "https://example.com/sf3d", Commit: commitA})
sf3d.ComfyUI.HuggingFaceRepo = "stabilityai/stable-fast-3d"
sf3d.ComfyUI.Checkpoint = "model.safetensors"
sf3d.Scaling = Scaling{Max: 1, IdleTimeout: 10 * time.Minute}
cat, err := New([]Model{flux, sf3d, inEnv("alone", "solo", "/w/alone.ckpt")})
if err != nil {
t.Fatal(err)
}
host, ok := cat.SharedBy("sf3d")
if !ok || host != "comfyui-env-art" {
t.Fatalf("sf3d shared by %q %v", host, ok)
}
if _, ok := cat.SharedBy("alone"); ok {
t.Error("an environment of one got a hidden entry")
}
em, _ := cat.Resolve(host)
if !em.IsEnvironment() || em.MakesImages() || em.MakesMeshes() || em.Scaling.IdleTimeout != 15*time.Minute ||
len(em.Shared.Members) != 2 || em.Shared.Members[1].HFRepo != "stabilityai/stable-fast-3d" {
t.Errorf("entry %+v", em)
}
p, ok := em.EngineProfile(em.Profiles[0].Name)
if !ok {
t.Fatal("no engine profile")
}
var ms []map[string]string
if err := json.Unmarshal([]byte(p.Env[SharedModelsEnv]), &ms); err != nil || len(ms) != 2 || ms[1]["source"] != "/w/sf3d/" {
t.Errorf("members for the agent %q %v", p.Env[SharedModelsEnv], err)
}
if !strings.Contains(p.Env[NodesEnv], "sf3d") {
t.Errorf("nodes %q", p.Env[NodesEnv])
}
// A catalog built again from its own models is the same catalog.
again, err := New(cat.Models())
if err != nil || len(again.Models()) != len(cat.Models()) {
t.Errorf("rebuilt: %v, %d models vs %d", err, len(again.Models()), len(cat.Models()))
}
}
+29
View File
@@ -371,6 +371,35 @@ func (c *Controller) endpoints() []endpoint.Info {
Weight: rep.Weight,
Priority: prof.Priority,
}
if m.Shared != nil {
// One replica answering for every member of its environment:
// offered under their names, each sent its own workflow and
// checkpoint. Its own hidden name nobody asks for.
info.Models = nil
info.Comfy = map[string]endpoint.ComfyRoute{}
ok := true
for _, sm := range m.Shared.Members {
w, err := c.workflow(sm.Workflow)
if err != nil {
c.log.Warn("a shared ComfyUI member's workflow could not be read; its replica is not offered",
"model", sm.Name, "replica", rep.ID, "err", err)
ok = false
break
}
route := endpoint.ComfyRoute{Workflow: w, Checkpoint: sm.Checkpoint}
for _, n := range append([]string{sm.Name}, sm.Aliases...) {
info.Models = append(info.Models, n)
info.Comfy[n] = route
}
if info.Workflow == "" {
info.Workflow, info.UpstreamModel = w, sm.Checkpoint
}
}
if ok {
out = append(out, info)
}
continue
}
if prof.Engine == catalog.EngineComfyUI {
workflow, ckpt := m.ComfyWorkflowFor(prof)
w, err := c.workflow(workflow)
+56 -2
View File
@@ -3,6 +3,7 @@ package control
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"log/slog"
@@ -59,6 +60,17 @@ type Leadership struct {
hookMu sync.Mutex
onLead []func()
// cut is a store this process could not reach, and back when it was
// reached again: Run's alone, so unlocked.
cut bool
back time.Time
}
// LeaseReader reads who holds a lease without competing for it: a store
// that has it lets a standby hold back after the store returns.
type LeaseReader interface {
Leadership(ctx context.Context, role string) (store.Lease, error)
}
// OnLead is called each time this process becomes the leader, after
@@ -91,8 +103,9 @@ func NewLeadership(st LeaseStore, ttl time.Duration, log *slog.Logger) *Leadersh
if ttl <= 0 {
// Long enough to survive a slow database or a brief network stall,
// short enough that a standby takes over before anyone has finished
// reading the alert.
ttl = 15 * time.Second
// reading the alert. A store out of reach for longer — its primary
// being replaced — does not move it: see holdBack.
ttl = 10 * time.Second
}
l := &Leadership{
store: st,
@@ -146,6 +159,9 @@ func (l *Leadership) attempt(ctx context.Context) {
if l.stopped.Load() {
return
}
if l.holdBack(ctx) {
return
}
lease, err := l.store.AcquireLeadership(ctx, controlRole, l.owner, l.ttl)
switch {
case err == nil && l.stopped.Load():
@@ -177,9 +193,47 @@ func (l *Leadership) attempt(ctx context.Context) {
// stop a cluster from healing itself.
l.log.Warn("could not reach the store to renew leadership; keeping it for now",
"err", err)
l.cut, l.back = true, time.Time{}
}
}
// holdBack is a standby leaving a lapsed lease alone for one round after the
// store returns from being out of reach.
//
// A store's primary that is lost takes longer to replace than the lease
// lives, so the lease has lapsed by the time the store answers again — for
// the leader as for the standbys, which all failed to reach it alike. The
// first to ask then took it, and it was as often a standby as the leader:
// leadership moved for nothing, and the new leader spent half a minute
// learning from the agents where the judges were, refusing requests
// meanwhile. The leader renews within a round of the store's return, so a
// standby that waits that round takes the lease only from a leader that is
// gone.
func (l *Leadership) holdBack(ctx context.Context) bool {
if l.leader.Load() || !l.cut {
return false
}
r, ok := l.store.(LeaseReader)
if !ok {
l.cut = false
return false
}
if _, err := r.Leadership(ctx, controlRole); err != nil && !errors.Is(err, sql.ErrNoRows) {
l.back = time.Time{}
return true
}
if l.back.IsZero() {
l.back = time.Now()
l.log.Info("the store answers again; leaving the lease to its holder for a round", "owner", l.owner)
}
// A round less a tenth, for the ticker's own jitter.
if time.Since(l.back) < l.renew-l.renew/10 {
return true
}
l.cut, l.back = false, time.Time{}
return false
}
// StepDown gives the lease up now, for a process on its way out: called
// before the agents are let go, so the standby they move to is already the
// leader, and before the process exits, which would otherwise race the
+79
View File
@@ -1,6 +1,8 @@
package control
import (
"context"
"errors"
"log/slog"
"path/filepath"
"testing"
@@ -75,3 +77,80 @@ func TestAStandbyIsToldWhenItBecomesTheLeader(t *testing.T) {
t.Fatal("b became the leader and was not told")
}
}
// cuttable is a store that can be out of reach, as one whose primary was lost.
type cuttable struct {
*store.DB
down bool
}
func (c *cuttable) AcquireLeadership(ctx context.Context, role, owner string, ttl time.Duration) (store.Lease, error) {
if c.down {
return store.Lease{}, errors.New("dial error: timeout")
}
return c.DB.AcquireLeadership(ctx, role, owner, ttl)
}
func (c *cuttable) Leadership(ctx context.Context, role string) (store.Lease, error) {
if c.down {
return store.Lease{}, errors.New("dial error: timeout")
}
return c.DB.Leadership(ctx, role)
}
// The store out of reach for longer than the lease lives, as while Patroni
// promotes a standby: when it answers again the lease has lapsed for all,
// and the leader keeps it rather than a standby taking it for nothing. A
// leader that is truly gone is still replaced, a round later.
func TestAStoreThatReturnsLeavesTheLeaderLeading(t *testing.T) {
st, err := store.Open(t.Context(), filepath.Join(t.TempDir(), "s.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
c := &cuttable{DB: st}
log := slog.New(slog.DiscardHandler)
ctx := t.Context()
const ttl = 150 * time.Millisecond
a, b := NewLeadership(c, ttl, log), NewLeadership(c, ttl, log)
a.attempt(ctx)
b.attempt(ctx)
if !a.IsLeader() || b.IsLeader() {
t.Fatal("a should lead, b stand by")
}
c.down = true
a.attempt(ctx)
b.attempt(ctx)
time.Sleep(ttl + 20*time.Millisecond) // the lease lapses while nobody reaches the store
c.down = false
b.attempt(ctx) // the standby is first to ask
if b.IsLeader() {
t.Fatal("a standby took the lease the moment the store came back")
}
a.attempt(ctx)
if !a.IsLeader() {
t.Fatal("the leader did not keep the lease")
}
time.Sleep(a.renew)
b.attempt(ctx)
if b.IsLeader() {
t.Error("the standby took the lease from a leader renewing it")
}
// Now the leader is gone: the standby takes over after its round.
c.down = true
b.attempt(ctx)
time.Sleep(ttl + 20*time.Millisecond)
c.down = false
b.attempt(ctx)
if b.IsLeader() {
t.Fatal("took it at once")
}
time.Sleep(b.renew)
b.attempt(ctx)
if !b.IsLeader() {
t.Error("a leader that was gone was not replaced")
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ func (s *Server) addCandidate(ctx context.Context, j *Job, p prepared, cn catalo
s.step(j, "smoke test", Running, "asking "+r.ID+" to say hello")
var reply string
var tps float64
if reply, tps, err = s.smoke(ctx, r, cand); err == nil {
if reply, tps, err = s.smoke(ctx, r, cand, s.embeds(cand)); err == nil {
s.step(j, "smoke test", Done, fmt.Sprintf("answered at %.1f tokens/s: %q", tps, reply))
} else {
s.step(j, "smoke test", Failed, err.Error())
+127 -4
View File
@@ -153,6 +153,13 @@ type Request struct {
// is written into the catalog, where clients and editors read it. Check
// a running replica with llmgw ctl tool-check before saying so.
Tools bool `json:"tools,omitempty"`
// Embeddings serves it as an embedding model, which its GGUF header
// says by itself for most: --embedding, many slots, a batch that holds
// a whole passage, /v1/embeddings.
Embeddings bool `json:"embeddings,omitempty"`
// sized is the caller having said --ctx-pool or --context-length, which
// an embedding model's defaults then leave alone.
sized bool
// CPUCores is what a replica computes on; zero takes half the smallest
// node it fits on.
CPUCores int `json:"cpu_cores,omitempty"`
@@ -268,6 +275,7 @@ func (s *Server) Start(req Request) (Job, error) {
if req.GuardFormat != "" && req.Replicas == 0 {
return Job{}, errors.New("a judge model must always run: give it --replicas 1 or more, not --on-demand")
}
req.sized = req.CtxPool > 0 || req.ContextLength > 0
if req.CtxPool <= 0 {
req.CtxPool = 16384
}
@@ -473,6 +481,30 @@ func (s *Server) run(j *Job) {
}
header = v.Header
}
if header.Embedding {
req.Embeddings = true
}
if req.Embeddings && !req.sized {
// Many passages at once rather than four: an import of a hundred
// thousand chunks went at the pace of four slots with the card a
// third busy.
req.ContextLength, req.CtxPool = embedSlotTokens, embedSlotTokens*embedSlots
}
// On one card, the weights and the context's cache have to fit it: a
// 70B with a 65536-token pool on a 24 GB card loaded its weights and then
// failed to make its context, every time, as did an 8B with 131072.
if len(layout) > 0 && layout[0].Class != "" && !layout[0].CPU {
fitted, why, err := fitPoolToCard(req, header, set.Bytes, layout[0].Class, max(layout[0].GPUsPerNode, 1))
if err != nil {
s.fail(j, "check", err)
return
}
if why != "" {
s.step(j, "check", Running, why)
req = fitted
}
}
j.Request = req
memGB := int((header.MemoryBytes(set.Bytes, req.CtxPool) + 1<<30 - 1) >> 30)
basis := fmt.Sprintf("weights %.1f GB + KV cache %.1f GB for %d tokens + engine",
gb(set.Bytes), gb(int64(req.CtxPool)*header.KVBytesPerToken()), req.CtxPool)
@@ -587,10 +619,24 @@ func profilesFor(layout []modelplan.Option, req Request, memGB, cores int) []cat
// slots of 8192, and the gateway, told 32768, sent requests the engine then
// refused.
func llamaArgs(req Request) []string {
return []string{"--ctx-size", strconv.Itoa(req.CtxPool),
args := []string{"--ctx-size", strconv.Itoa(req.CtxPool),
"--parallel", strconv.Itoa(max(1, req.CtxPool/req.ContextLength))}
if req.Embeddings {
// A passage is embedded in one batch: with the default 512, one of
// 516 tokens was refused.
n := strconv.Itoa(req.ContextLength)
args = append([]string{"--embedding"}, append(args, "--batch-size", n, "--ubatch-size", n)...)
}
return args
}
// An embedding model's slots, when its caller did not size them: passages
// of up to a thousand tokens, thirty-two at a time.
const (
embedSlotTokens = 1024
embedSlots = 32
)
// maxOutput is the longest answer the catalog allows: a quarter of the
// context, from 1024 to 8192. 1024 everywhere cut an editor's agent off in
// the middle of the file it was writing.
@@ -620,7 +666,7 @@ func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.F
RoutingProfile: "chat", ContextLength: req.ContextLength, MaxOutput: maxOutput(req.ContextLength),
Profiles: profs,
Scaling: catalog.Scaling{Min: req.Replicas, Max: max(req.Replicas, 1)},
Capabilities: catalog.Capabilities{Vision: req.Projector != "", Tools: req.Tools},
Capabilities: catalog.Capabilities{Vision: req.Projector != "", Tools: req.Tools, Embeddings: req.Embeddings},
Origin: &catalog.Origin{Repo: repo.ID, Revision: repo.Revision, Files: originFiles(set)},
}
if req.GuardFormat != "" {
@@ -655,7 +701,7 @@ func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.F
// 6. Does it answer.
s.step(j, "smoke test", Running, "asking "+rep.ID+" to say hello")
reply, tps, err := s.smoke(ctx, rep, name)
reply, tps, err := s.smoke(ctx, rep, name, req.Embeddings)
if err != nil {
s.fail(j, "smoke test", err)
return
@@ -859,7 +905,10 @@ func (s *Server) waitReady(ctx context.Context, j *Job, name string, hardware bo
}
// smoke sends one short request straight to the replica, with its engine key.
func (s *Server) smoke(ctx context.Context, r reconciler.Replica, name string) (string, float64, error) {
func (s *Server) smoke(ctx context.Context, r reconciler.Replica, name string, embeddings bool) (string, float64, error) {
if embeddings {
return s.smokeEmbeddings(ctx, r, name)
}
body, _ := json.Marshal(map[string]any{
"model": name,
"max_tokens": 32,
@@ -989,3 +1038,77 @@ func pickSafetensors(repo hf.Repo, _ Request) (hf.FileSet, error) {
}
return set, nil
}
// smokeEmbeddings asks an embedding model for one vector, and says its
// length: an embedding model does not chat.
func (s *Server) smokeEmbeddings(ctx context.Context, r reconciler.Replica, name string) (string, float64, error) {
body, _ := json.Marshal(map[string]any{"model": name, "input": "hello"})
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
strings.TrimRight(r.Endpoint, "/")+"/v1/embeddings", bytes.NewReader(body))
if err != nil {
return "", 0, err
}
req.Header.Set("Content-Type", "application/json")
if k := s.deps.Fleet.EngineKey(r.ID); k != "" {
req.Header.Set("Authorization", "Bearer "+k)
}
resp, err := s.deps.HTTP.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("the replica answered %s: %s", resp.Status, strings.TrimSpace(string(b)))
}
var out struct {
Data []struct {
Embedding []float64 `json:"embedding"`
} `json:"data"`
}
if err := json.Unmarshal(b, &out); err != nil || len(out.Data) == 0 || len(out.Data[0].Embedding) == 0 {
return "", 0, fmt.Errorf("the replica answered no vector: %s", strings.TrimSpace(string(b)))
}
return fmt.Sprintf("a vector of %d dimensions", len(out.Data[0].Embedding)), 0, nil
}
// embeds is whether the catalog serves name as an embedding model, for a
// smoke test of one already there (a canary, an update).
func (s *Server) embeds(name string) bool {
if s.deps.Current == nil {
return false
}
for _, m := range s.deps.Current() {
if m.Name == name {
return m.Capabilities.Embeddings
}
}
return false
}
// fitPoolToCard shrinks a context pool the cards cannot hold to one they
// can, saying so, and refuses weights that do not fit at all. The slots stay
// as many as asked while each keeps 2048 tokens, then fewer.
func fitPoolToCard(req Request, h gguf.Header, weights int64, class string, gpus int) (Request, string, error) {
maxTok := h.PoolThatFits(weights, gguf.ClassMemGB(class), gpus)
switch {
case maxTok == 0 || req.CtxPool <= maxTok:
return req, "", nil
case maxTok < 0:
return req, "", fmt.Errorf("its weights (%.1f GB) do not fit %d card(s) of %s with room to run: "+
"choose a smaller quantisation, or more cards", gb(weights), gpus, class)
}
slots := max(1, req.CtxPool/max(req.ContextLength, 1))
for slots > 1 && maxTok/slots < 2048 {
slots--
}
per := (maxTok / slots) / 1024 * 1024
if per < 1024 {
return req, "", fmt.Errorf("its weights (%.1f GB) leave room on %s for a context of %d tokens only",
gb(weights), class, maxTok)
}
was := req.CtxPool
req.ContextLength, req.CtxPool = per, per*slots
return req, fmt.Sprintf("a %d-token pool does not fit %s beside %.1f GB of weights: %d slots of %d tokens instead",
was, class, gb(weights), slots, per), nil
}
@@ -5,6 +5,7 @@ import (
"context"
"encoding/binary"
"fmt"
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
"net/http"
"net/http/httptest"
"slices"
@@ -397,3 +398,33 @@ func TestOnlyTheLastFinishedJobsAreRemembered(t *testing.T) {
t.Error("the oldest finished job is still remembered")
}
}
func TestAnEmbeddingModelIsServedForEmbedding(t *testing.T) {
// Four slots and a 512-token batch: an import went at four passages a
// pass, and one passage of 516 tokens was refused.
args := strings.Join(llamaArgs(Request{Embeddings: true, ContextLength: embedSlotTokens,
CtxPool: embedSlotTokens * embedSlots}), " ")
for _, want := range []string{"--embedding", "--ctx-size 32768", "--parallel 32", "--batch-size 1024", "--ubatch-size 1024"} {
if !strings.Contains(args, want) {
t.Errorf("%q not in %q", want, args)
}
}
if args := strings.Join(llamaArgs(Request{ContextLength: 4096, CtxPool: 16384}), " "); strings.Contains(args, "embedding") {
t.Errorf("a chat model got %q", args)
}
}
func TestAPoolTheCardCannotHoldIsShrunkNotStarted(t *testing.T) {
h := gguf.Header{Architecture: "apertus", BlockCount: 32, HeadCount: 32, HeadCountKV: 8, EmbeddingLength: 4096}
req := Request{CtxPool: 131072, ContextLength: 32768}
got, why, err := fitPoolToCard(req, h, int64(8.5*(1<<30)), "6000-24g", 1)
if err != nil || why == "" || got.CtxPool > 105000 || got.CtxPool/got.ContextLength != 4 {
t.Errorf("fitted %+v %q %v", got, why, err)
}
if _, _, err := fitPoolToCard(req, h, int64(30*(1<<30)), "6000-24g", 1); err == nil {
t.Error("weights larger than the card were not refused")
}
if got, why, _ := fitPoolToCard(Request{CtxPool: 16384, ContextLength: 4096}, h, int64(8.5*(1<<30)), "6000-24g", 1); why != "" || got.CtxPool != 16384 {
t.Errorf("a pool that fits was changed: %+v %q", got, why)
}
}
+1 -1
View File
@@ -689,7 +689,7 @@ func (s *Server) replace(ctx context.Context, j *Job, name string) error {
}
s.step(j, "replicas", Done, fmt.Sprintf("%d replaced", len(old)))
s.step(j, "smoke test", Running, "asking "+ready[0].ID+" to say hello")
reply, tps, err := s.smoke(ctx, ready[0], name)
reply, tps, err := s.smoke(ctx, ready[0], name, s.embeds(name))
if err != nil {
s.step(j, "smoke test", Failed, err.Error())
return err
+24 -1
View File
@@ -55,7 +55,7 @@ func (r *Reconciler) IdleFor(id string) (time.Duration, bool) {
// from "there is genuinely nothing serving this", which are different answers
// to give a client.
func (r *Reconciler) Want(model string) bool {
m, ok := r.cat.Load().Resolve(model)
m, ok := r.cat.Load().Resolve(r.hostOf(model))
if !ok || !m.Scaling.OnDemand() {
return false
}
@@ -127,6 +127,9 @@ func (r *Reconciler) baseFor(m catalog.Model) int {
if m.IsExternal() {
return 0 // served elsewhere: nothing to place, whatever its scaling says
}
if _, shared := r.cat.Load().SharedBy(m.Name); shared {
return 0 // its environment's replica serves it
}
if lo := r.minFor(m); !m.Scaling.OnDemand() || lo > 0 {
return lo
}
@@ -199,6 +202,17 @@ type Load struct {
// were measured to hold, so scaling down does not land on the edge.
func (r *Reconciler) RecordLoad(loads map[string]Load) {
now := r.now()
// A shared replica's load is its members' together.
for name, l := range loads {
if host := r.hostOf(name); host != name {
h := loads[host]
h.Replicas = max(h.Replicas, l.Replicas)
h.Running += l.Running
h.Waiting += l.Waiting
loads[host] = h
delete(loads, name)
}
}
// The profile each model's replicas run on, for its measurement; read
// before the loop, which holds the lock.
running := map[string]string{}
@@ -280,3 +294,12 @@ func (r *Reconciler) RecordLoad(loads map[string]Load) {
// after a scale-down may be. Below one so that the load that was there a
// minute ago does not put them straight back over.
const scaleDownHeadroom = 0.7
// hostOf is the model whose replicas serve name: its environment's hidden
// entry when it shares one, else itself.
func (r *Reconciler) hostOf(name string) string {
if host, ok := r.cat.Load().SharedBy(name); ok {
return host
}
return name
}
+35 -2
View File
@@ -88,6 +88,8 @@ func (r *Reconciler) forgetWantIfGone(model, stopped string) {
// longest idle first.
func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
cat := r.cat.Load()
// A member never evicts the replica that serves it.
wantedBy = r.hostOf(wantedBy)
now := r.now()
wantRank := -1
@@ -120,7 +122,7 @@ func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
continue
}
idle := now.Sub(rep.LastUsedAt)
if idle < r.cfg.EvictionGrace {
if idle < r.evictionGrace() {
continue
}
// A replica that has only just started is left alone however idle it
@@ -128,7 +130,7 @@ func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
// otherwise take turns evicting each other, paying a model load each
// time — and a load is expensive enough that waiting is strictly
// better than winning.
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.cfg.MinLifetime {
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.minLifetime() {
continue
}
nodes := map[string]struct{}{}
@@ -156,3 +158,34 @@ func (r *Reconciler) EvictionReason(model string) string {
}
return "nothing idle enough to stop"
}
// evictionGrace is how long an on-demand replica must go unasked-for before
// another model may have its card: the cluster's setting, else the
// configuration's.
func (r *Reconciler) evictionGrace() time.Duration {
if d := time.Duration(r.evictGrace.Load()); d > 0 {
return d
}
return r.cfg.EvictionGrace
}
// minLifetime is how long a replica is safe from eviction once it is up.
func (r *Reconciler) minLifetime() time.Duration {
if d := time.Duration(r.minLife.Load()); d > 0 {
return d
}
return r.cfg.MinLifetime
}
// SetEvictionTimes puts the cluster's on_demand settings in force: how long
// an idle on-demand replica keeps its card, and how long a new one is safe.
// Zero keeps the configuration's.
//
// On one card shared by several models these are the wait between them: a
// 3D job after a picture waited out five minutes of a picture model it no
// longer needed.
func (r *Reconciler) SetEvictionTimes(afterIdle, minLifetime time.Duration) {
r.evictGrace.Store(int64(afterIdle))
r.minLife.Store(int64(minLifetime))
r.Notify()
}
+5 -5
View File
@@ -130,7 +130,7 @@ func (r *Reconciler) ModelHealth() []ModelHealth {
// Unservable says why a model cannot be served at all, so a request for it is
// told now rather than asked to retry something that will never come up.
func (r *Reconciler) Unservable(model string) (string, bool) {
m, ok := r.cat.Load().Resolve(model)
m, ok := r.cat.Load().Resolve(r.hostOf(model))
if !ok {
return "", false
}
@@ -176,7 +176,7 @@ func (r *Reconciler) Unservable(model string) (string, bool) {
// on another model; eta is zero when when the holder lets go is not known.
func (r *Reconciler) WaitFor(model string) (heldBy string, eta time.Duration, ok bool) {
cat := r.cat.Load()
m, found := cat.Resolve(model)
m, found := cat.Resolve(r.hostOf(model))
if !found || !m.Scaling.OnDemand() {
return "", 0, false
}
@@ -205,9 +205,9 @@ func (r *Reconciler) WaitFor(model string) (heldBy string, eta time.Duration, ok
// Evicted sooner, when it is no higher a class than the model
// waiting; one put first is waited for to its idle timeout.
if catalog.ClassRank(other.EffectiveClass()) <= catalog.ClassRank(m.EffectiveClass()) {
evict := rep.LastUsedAt.Add(r.cfg.EvictionGrace)
if !rep.ReadyAt.IsZero() && rep.ReadyAt.Add(r.cfg.MinLifetime).After(evict) {
evict = rep.ReadyAt.Add(r.cfg.MinLifetime)
evict := rep.LastUsedAt.Add(r.evictionGrace())
if !rep.ReadyAt.IsZero() && rep.ReadyAt.Add(r.minLifetime()).After(evict) {
evict = rep.ReadyAt.Add(r.minLifetime())
}
if evict.Before(h.release) {
h.release = evict
+2 -2
View File
@@ -249,7 +249,7 @@ func (r *Reconciler) victims(m catalog.Model) []victim {
aboveMin := i < above
tier := -1
switch {
case rank < myRank && other.Scaling.OnDemand() && idleFor(rep, now) >= r.cfg.EvictionGrace:
case rank < myRank && other.Scaling.OnDemand() && idleFor(rep, now) >= r.evictionGrace():
tier = 0
case rank < myRank && aboveMin:
tier = 2
@@ -275,7 +275,7 @@ func (r *Reconciler) victims(m catalog.Model) []victim {
}
// Just started: stopping it throws away a model load that has
// only now been paid for.
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.cfg.MinLifetime {
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.minLifetime() {
continue
}
if len(nodesOf(rep)) > 1 {
@@ -264,6 +264,9 @@ type Reconciler struct {
// because it is state about this moment and not about what the model is.
// A model nobody has wanted has no entry and is simply not running.
wanted map[string]time.Time
// evictGrace and minLife are the cluster's on_demand settings, as
// durations; zero keeps cfg's.
evictGrace, minLife atomic.Int64
// coldStart is how long each model's last replica took from placed to
// ready, for telling a request waiting on it about how long it waits.
coldStart map[string]time.Duration
@@ -1763,6 +1763,31 @@ func mustResolve(t *testing.T, h *harness, name string) catalog.Model {
return m
}
func TestTheClustersOnDemandSettingsShortenTheWaitForACard(t *testing.T) {
// One card, a picture model and a 3D model: a mesh after a picture
// waited out five minutes of the picture model's protection.
h := newHarness(t, testModel(1), gpuNode("1", 4))
h.r.cat.Store(twoOnDemandModels(t))
h.r.Want("llama")
h.r.Reconcile(t.Context())
h.markReady()
reps := h.r.Replicas()
h.r.RecordUsage(map[string]time.Time{reps[0].ID: h.clock.now()})
h.advance(90 * time.Second)
if got := h.r.evictable("second"); len(got) != 0 {
t.Fatalf("offered at 90 s with the defaults: %+v", got)
}
h.r.SetEvictionTimes(30*time.Second, time.Minute)
if got := h.r.evictable("second"); len(got) != 1 {
t.Errorf("not offered at 90 s with 30 s idle and 1 min lifetime: %+v", got)
}
// Zero goes back to the configuration's.
h.r.SetEvictionTimes(0, 0)
if got := h.r.evictable("second"); len(got) != 0 {
t.Errorf("offered after the settings were cleared: %+v", got)
}
}
func TestAFreshlyStartedReplicaIsNotEvicted(t *testing.T) {
// What stops two models on a cluster with room for one from taking turns
// evicting each other, paying a model load each time. A load is expensive
@@ -0,0 +1,57 @@
package reconciler
import (
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
)
func sharedCatalog(t *testing.T) *catalog.Catalog {
t.Helper()
mk := func(name, out string) catalog.Model {
return catalog.Model{Name: name, Source: "/w/" + name + ".safetensors",
ComfyUI: &catalog.ComfyUI{Environment: "art", Workflow: "flux-schnell", Output: out},
Scaling: catalog.Scaling{Max: 1, IdleTimeout: time.Hour},
Profiles: []catalog.Profile{{Name: "p", Engine: catalog.EngineComfyUI, GPUClass: "h100-80g", TP: 1}}}
}
cat, err := catalog.New([]catalog.Model{mk("flux", catalog.OutputPictures), mk("sf3d", catalog.OutputMeshes)})
if err != nil {
t.Fatal(err)
}
return cat
}
func TestAMemberAskedForStartsItsEnvironmentsOneReplica(t *testing.T) {
// A picture model and a 3D model on one card took turns, each from
// nothing: in one environment, asking for either starts one replica
// that serves both, and neither gets one of its own.
h := newHarness(t, testModel(1), gpuNode("1", 1))
h.r.cat.Store(sharedCatalog(t))
if !h.r.Want("sf3d") {
t.Fatal("a member was not wanted")
}
h.r.Reconcile(t.Context())
h.markReady()
h.r.Want("flux")
h.r.Reconcile(t.Context())
var models []string
for _, rep := range h.r.Replicas() {
if rep.State.Counts() {
models = append(models, rep.Model)
}
}
if len(models) != 1 || models[0] != "comfyui-env-art" {
t.Fatalf("replicas %v", models)
}
// The member's sibling never evicts the replica serving it.
reps := h.r.Replicas()
h.r.RecordUsage(map[string]time.Time{reps[0].ID: h.clock.now()})
h.advance(DefaultConfig().MinLifetime + DefaultConfig().EvictionGrace + time.Minute)
if got := h.r.evictable("flux"); len(got) != 0 {
t.Errorf("a member was offered its own environment's replica: %+v", got)
}
if _, _, ok := h.r.WaitFor("flux"); ok {
t.Error("a member served by a replica that is up was said to be waiting")
}
}
+6 -1
View File
@@ -91,7 +91,7 @@ var Reference = []Block{
{Key: "timezone", Type: "string", Doc: "Timezone is where models' scaling schedules are read, an IANA name\nlike Europe/Istanbul; empty is the control plane's own.", Nested: ""},
{Key: "eval_dir", Type: "string", Doc: "EvalDir is where quality suites are read from by name, for `llmgw ctl\neval MODEL --suite NAME`, a model's eval gate and the console. The\nsuites the binary carries (arithmetic, json-format) are found when it\nhas none of the name; a suite here of the same name replaces one.", Nested: ""},
{Key: "enabled", Type: "bool", Doc: "Enabled runs the control plane even when configuration declares no\nmodels.\n\nWithout it, a deployment whose catalog lives entirely in the database\ncannot start: control plane mode is inferred from the config file, so an\nempty models block means \"router only\", the process refuses to start for\nwant of anything to serve, and the models it would have loaded are never\nread. That is a cluster that cannot be managed at runtime because it is\nmanaged at runtime. AgentListen cannot carry this signal — it has a\ndefault, so every deployment would become a control plane.", Nested: ""},
{Key: "lease_ttl", Type: "time.Duration", Doc: "LeaseTTL is how long this control plane's claim to be the one placing\nwork stands without being renewed.\n\nOnly meaningful with a shared store, where a second control plane can\nsee the claim. Long enough to survive a slow query or a brief network\nstall, short enough that a standby takes over before anyone has finished\nreading the alert. Zero takes the default.", Nested: ""},
{Key: "lease_ttl", Type: "time.Duration", Doc: "LeaseTTL is how long this control plane's claim to be the one placing\nwork stands without being renewed.\n\nOnly meaningful with a shared store, where a second control plane can\nsee the claim. Long enough to survive a slow query or a brief network\nstall, short enough that a standby takes over before anyone has finished\nreading the alert. Zero takes the default, 10s.", Nested: ""},
}},
{Package: "catalog", Name: "CustomNode", Title: "CustomNode", Doc: "", Fields: []Field{
{Key: "repo", Type: "string", Doc: "Repo is the repository, https only.", Nested: ""},
@@ -260,6 +260,10 @@ var Reference = []Block{
{Key: "mirror", Type: "string", Doc: "Mirror is the mirror's file address, as nodes reach it:\nhttp://10.0.0.5:8081 — the port model files are served on, not the\nmanagement API. Set, the cluster is offline: nodes install engines\nfrom it and never from their publishers, releases come from it unless\nreleases names another place, and no registry is asked whether a\nmodel has newer weights. Python engines (vLLM, SGLang) come from PyPI,\nwhich no bundle carries; on an offline node they are installed by hand.", Nested: ""},
{Key: "checksums", Type: "string", Doc: "Checksums is the file of engine checksums this control plane trusts,\nkept by llmgw bundle import and llmgw bundle trust; every engine file\na node fetches from the mirror is checked against it. Read again\nwhenever it changes. Empty is /etc/llmgw/offline.sha256.", Nested: ""},
}},
{Package: "config", Name: "OnDemandSettings", Title: "OnDemandSettings", Doc: "", Fields: []Field{
{Key: "evict_after_idle", Type: "time.Duration", Doc: "EvictAfterIdle is how long an on-demand replica must go unasked-for\nbefore a model that wants its card may have it. Zero keeps 2m.", Nested: ""},
{Key: "min_lifetime", Type: "time.Duration", Doc: "MinLifetime is how long a replica that has just come up is safe from\nthat, so two models do not take turns paying for loads. Zero keeps\n5m. On one card shared by a picture model and a 3D model, 1m each\nbrings the wait between a picture and its mesh to about a minute.\nBoth are in force on the leader within seconds.", Nested: ""},
}},
{Package: "discovery", Name: "Options", Title: "Options", Doc: "", Fields: []Field{
{Key: "nvidia_smi", Type: "string", Doc: "NvidiaSMI is the path to nvidia-smi; empty means look it up on PATH.\nWorth configuring on nodes where the driver lives outside the default\npaths, which containerized driver installs often do.", Nested: ""},
{Key: "ib_root", Type: "string", Doc: "IBRoot is the sysfs InfiniBand directory.", Nested: ""},
@@ -442,6 +446,7 @@ var Reference = []Block{
{Key: "serving", Type: "ServingSettings", Doc: "Serving stops and starts the whole cluster's service (llmgw stop, llmgw\nstart).", Nested: "config.ServingSettings"},
{Key: "firewall", Type: "FirewallSettings", Doc: "Firewall turns llmgw's firewall on every machine on or off.", Nested: "config.FirewallSettings"},
{Key: "presets", Type: "PresetSettings", Doc: "Presets is how the models that come with llmgw reach the catalog.", Nested: "config.PresetSettings"},
{Key: "on_demand", Type: "OnDemandSettings", Doc: "OnDemand is how models that load when asked for take turns on the\ncards.", Nested: "config.OnDemandSettings"},
{Key: "agents", Type: "AgentSettings", Doc: "Agents are the nodes' agent settings: agent.yaml's keys but those an\nagent needs to start, for every node (default) and for one\n(nodes.<id>). Sent to the agents as they change; an agent whose\nsettings changed starts again on them, its engines serving\nthroughout.", Nested: "config.AgentSettings"},
{Key: "firewall_networks", Type: "[]string", Doc: "FirewallNetworks are networks, besides those each machine reaches the\ncluster on, that llmgw's internal ports answer: a monitoring host on\nanother subnet, nodes behind a router. Written into every machine's\nfirewall by `server init`, `node add` and `server add`.", Nested: ""},
{Key: "backup_recipients", Type: "[]string", Doc: "BackupRecipients encrypt every archive `llmgw server backup` writes, to\nwhoever holds one of their keys: age recipients (age1…) or SSH public\nkeys (ssh-ed25519 …, ssh-rsa …), as `age` takes them. An archive holds\nthe certificate authority's key and every secret in the store, and is\ncopied off the machine; encrypted, the copy is safe wherever it lands.\nEmpty writes archives unencrypted, readable by root only.", Nested: ""},
+1
View File
@@ -36,6 +36,7 @@ var Sources = []string{
"internal/config/firewall.go",
"internal/config/serving.go",
"internal/config/presets.go",
"internal/config/ondemand.go",
"internal/config/agents.go",
"internal/mail/mail.go",
"internal/notify/notify.go",
+43
View File
@@ -41,6 +41,10 @@ type Header struct {
// them; otherwise a head is EmbeddingLength / HeadCount.
KeyLength uint64
ValueLength uint64
// Embedding is whether the model pools its tokens into one vector
// (<arch>.pooling_type set and not none): an embedding model, which
// llama.cpp serves with --embedding and does not chat.
Embedding bool
}
// KVBytesPerToken is what one token of context costs in llama.cpp's default
@@ -265,6 +269,8 @@ func Read(r io.Reader) (Header, error) {
h.KeyLength = num
case "attention.value_length":
h.ValueLength = num
case "pooling_type":
h.Embedding = num > 0
}
}
// The tokenizer's arrays — megabytes of vocabulary — come after the
@@ -372,3 +378,40 @@ func Check(ctx context.Context, url, llamaVersion string) Verdict {
v.Header = h
return v
}
// CardUsable is the share of a card's memory a model may take: the rest is
// CUDA's own and the engine's compute buffers.
const CardUsable = 0.9
// PoolThatFits is the largest context pool, in tokens, whose KV cache fits
// on gpus cards of cardGB beside weights bytes of weights and a gigabyte for
// the engine: -1 when the weights alone do not, 0 when the header or the
// card does not say enough to tell.
func (h Header) PoolThatFits(weights int64, cardGB, gpus int) int {
perTok := h.KVBytesPerToken()
if cardGB <= 0 || perTok <= 0 || weights <= 0 {
return 0
}
budget := float64(cardGB*max(gpus, 1))*CardUsable*(1<<30) - float64(weights) - (1 << 30)
if budget <= 0 {
return -1
}
return int(budget / float64(perTok))
}
// ClassMemGB is a card's memory as its class names it: 6000-24g is 24, 0
// for a class that does not say.
func ClassMemGB(class string) int {
i := strings.LastIndex(class, "-")
if i < 0 || !strings.HasSuffix(class, "g") {
return 0
}
n := 0
for _, c := range strings.TrimSuffix(class[i+1:], "g") {
if c < '0' || c > '9' {
return 0
}
n = n*10 + int(c-'0')
}
return n
}
+47
View File
@@ -163,3 +163,50 @@ func TestArraysNestedDeepAreRefused(t *testing.T) {
t.Errorf("ten thousand nested arrays: %v", err)
}
}
func TestAnEmbeddingModelIsKnownByItsPooling(t *testing.T) {
build := func(pooling uint32) []byte {
var b bytes.Buffer
w := func(v any) { _ = binary.Write(&b, binary.LittleEndian, v) }
str := func(s string) { w(uint64(len(s))); b.WriteString(s) }
u32 := func(k string, v uint32) { str(k); w(uint32(tUint32)); w(v) }
b.WriteString("GGUF")
w(uint32(3))
w(uint64(0))
w(uint64(3))
str("general.architecture")
w(uint32(tString))
str("bert")
u32("bert.block_count", 24)
u32("bert.pooling_type", pooling)
return b.Bytes()
}
if h, err := Read(bytes.NewReader(build(2))); err != nil || !h.Embedding {
t.Errorf("CLS pooling: %+v %v", h, err)
}
if h, err := Read(bytes.NewReader(build(0))); err != nil || h.Embedding {
t.Errorf("no pooling: %+v %v", h, err)
}
}
func TestAPoolIsSizedToTheCard(t *testing.T) {
// Apertus-8B's shape: 32 layers, 8 KV heads of 128, 128 KiB a token.
h, err := Read(bytes.NewReader(shapedHeader("apertus", 32, 32, 8, 4096, true)))
if err != nil {
t.Fatal(err)
}
weights := int64(8.5 * (1 << 30))
most := h.PoolThatFits(weights, ClassMemGB("6000-24g"), 1)
if most < 90000 || most > 105000 {
t.Errorf("an 8B on 24 GB holds %d tokens of pool", most)
}
if most >= 131072 {
t.Error("the 131072 pool that failed on the card would pass")
}
if got := h.PoolThatFits(int64(30*(1<<30)), 24, 1); got != -1 {
t.Errorf("30 GB of weights on 24 GB: %d", got)
}
if ClassMemGB("t4-15g") != 15 || ClassMemGB("cpu") != 0 || ClassMemGB("v100-32g") != 32 {
t.Error("class memory misread")
}
}
+59 -14
View File
@@ -198,25 +198,70 @@ func (j *judge) ask(ctx context.Context, jp *JudgePolicy, user, answer string) (
}
j.mu.Unlock()
err := errNoJudge
for _, model := range models {
var res judgment
res, err = j.askOne(ctx, jp, model, user, answer)
if err == nil {
j.remember(key, res)
return res, nil
start := time.Now()
for {
err := errNoJudge
var failed []string
var errs []error
// fast is every judge failing at once, as one does that is not
// there yet: the moment a control plane takes the lead, before
// the agents have told it where the judges are.
fast := true
for _, model := range models {
var res judgment
t := time.Now()
res, err = j.askOne(ctx, jp, model, user, answer)
if err == nil {
// The judges before it that failed are said to have, as
// one on a GPU that is down while the CPUs' answers.
j.report(failed, errs)
j.remember(key, res)
return res, nil
}
failed, errs = append(failed, model), append(errs, err)
if time.Since(t) > judgeFastFailure || errors.Is(err, errJudgeFormat) {
fast = false
}
if ctx.Err() != nil {
// The request itself is gone: no judge after this one is wanted.
break
}
}
if j.failed != nil {
j.failed(model, err)
}
if ctx.Err() != nil {
// The request itself is gone: no judge after this one is wanted.
break
// Asked again while the budget lasts, rather than refused at once:
// with fail: closed, every request in those seconds was a 503.
if fast && ctx.Err() == nil && time.Since(start)+judgeRetryEvery < jp.budget() {
select {
case <-ctx.Done():
case <-time.After(judgeRetryEvery):
}
if ctx.Err() == nil {
continue
}
}
// Told once, for the last round: not twenty times in five seconds.
j.report(failed, errs)
return judgment{}, err
}
return judgment{}, err
}
// report tells of each judge that gave no verdict.
func (j *judge) report(models []string, errs []error) {
if j.failed == nil {
return
}
for i, model := range models {
j.failed(model, errs[i])
}
}
// A judge that fails within judgeFastFailure is one that is not there, and
// is asked again every judgeRetryEvery while the policy's budget lasts. One
// that is slow, or answers what cannot be read, is not.
const (
judgeFastFailure = time.Second
judgeRetryEvery = 250 * time.Millisecond
)
// askOne asks one judge, waiting the policy's budget for it.
func (j *judge) askOne(ctx context.Context, jp *JudgePolicy, model, user, answer string) (res judgment, err error) {
start := time.Now()
+42 -1
View File
@@ -222,7 +222,7 @@ func TestJudgesAreAskedInTurn(t *testing.T) {
var asked []string
gpuDown := errors.New("no replica of gpu-judge is serving")
g := gate(t, lib(map[string]Policy{"p": {Fail: "closed",
Judge: &JudgePolicy{Models: []string{"gpu-judge", "cpu-judge"}, Input: "block"}}}, "p"))
Judge: &JudgePolicy{Models: []string{"gpu-judge", "cpu-judge"}, Input: "block", LatencyBudget: 600 * time.Millisecond}}}, "p"))
g.SetJudge(func(_ context.Context, model, _ string, body []byte) ([]byte, error) {
asked = append(asked, model)
if model == "gpu-judge" {
@@ -317,3 +317,44 @@ func TestALongTextIsJudgedByItsEnds(t *testing.T) {
t.Errorf("a short text was cut: %q", got)
}
}
// The moment a control plane takes the lead it knows no judge yet; one that
// arrives within the budget judges the request, instead of fail: closed
// refusing it at once. One that is slow is not asked again.
func TestAJudgeNotThereYetIsWaitedForWithinTheBudget(t *testing.T) {
ctx := context.Background()
g := gate(t, lib(map[string]Policy{"p": {Fail: "closed",
Judge: &JudgePolicy{Model: "guard", Input: "block", LatencyBudget: 2 * time.Second}}}, "p"))
arrives := time.Now().Add(400 * time.Millisecond)
calls := 0
g.SetJudge(func(context.Context, string, string, []byte) ([]byte, error) {
calls++
if time.Now().Before(arrives) {
return nil, errors.New("no replica of guard is serving")
}
return json.Marshal(map[string]any{"choices": []any{map[string]any{"text": "Safety: Safe\nCategories: None"}}})
}, func(string) (string, []string, bool) { return "qwen3guard", nil, true })
var alarmed []string
g.SetJudgeAlarm(func(model string, _ error) { alarmed = append(alarmed, model) })
if v, err := g.Input(ctx, Subject{}, chat("user", "hello")); err != nil || v.Action != Allow {
t.Fatalf("refused while the judge was on its way: %+v %v", v, err)
}
if calls < 2 || len(alarmed) != 0 {
t.Errorf("asked %d times, alarms %v", calls, alarmed)
}
// Slow, and failing: given up on after one try, not the whole budget again.
calls = 0
g.SetJudge(func(ctx context.Context, _, _ string, _ []byte) ([]byte, error) {
calls++
time.Sleep(1100 * time.Millisecond)
return nil, errors.New("overloaded")
}, func(string) (string, []string, bool) { return "qwen3guard", nil, true })
start := time.Now()
if _, err := g.Input(ctx, Subject{}, chat("user", "hello again")); err == nil {
t.Error("fail closed served what no judge read")
}
if calls != 1 || time.Since(start) > 1800*time.Millisecond {
t.Errorf("a slow judge was asked %d times over %s", calls, time.Since(start))
}
}
+5 -1
View File
@@ -258,7 +258,11 @@ type Result struct {
// redact keeps a webhook's host and drops its path, where Slack and others
// put the secret.
func redact(u string) string {
func redact(u string) string { return Redact(u) }
// Redact is a webhook's address as it may be shown: its host, not the path
// that is its secret.
func Redact(u string) string {
if i := strings.Index(u, "://"); i >= 0 {
if j := strings.IndexByte(u[i+3:], '/'); j >= 0 {
return u[:i+3+j] + "/…"
+4 -4
View File
@@ -169,9 +169,9 @@ etcd3:
%[14]s
bootstrap:
dcs:
ttl: 30
loop_wait: 10
retry_timeout: 10
ttl: %[15]d
loop_wait: %[16]d
retry_timeout: %[17]d
maximum_lag_on_failover: 1048576
postgresql:
use_pg_rewind: true
@@ -211,7 +211,7 @@ postgresql:
parameters:
unix_socket_directories: /var/run/postgresql
`, Scope, m.Name, m.IP, PatroniPort, strings.Join(hosts, ","), version, s.Superuser, s.Replicator, hbaLines,
s.RestAPI, RestUser, sslLines, restTLSLines(tls), etcdTLSLines(tls))
s.RestAPI, RestUser, sslLines, restTLSLines(tls), etcdTLSLines(tls), leaderTTL, loopWait, retryTimeout)
b.WriteString(`
tags:
nofailover: false
+39
View File
@@ -559,3 +559,42 @@ func TestAMemberLeftPendingARestartIsRestartedOnTheNextRun(t *testing.T) {
t.Errorf("restarted %v; only the member pending", restarted)
}
}
func TestALostPrimaryIsReplacedWithinTwentySeconds(t *testing.T) {
cfg := map[string]any{"ttl": 30, "loop_wait": 10, "retry_timeout": 10}
patches := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
_ = json.NewEncoder(w).Encode(cfg)
case http.MethodPatch:
patches++
_ = json.NewDecoder(r.Body).Decode(&cfg)
}
}))
defer srv.Close()
u, _ := url.Parse(srv.URL)
old := restPort
restPort, _ = strconv.Atoi(u.Port())
defer func() { restPort = old }()
ca, err := tlsconf.NewCA("test")
if err != nil {
t.Fatal(err)
}
s := Setup{DB: []Node{{Member{"m", "127.0.0.1"}, &machine{}}}, CA: ca}
for range 2 {
if err := s.timings(context.Background(), Secrets{RestAPI: "pw"}); err != nil {
t.Fatal(err)
}
}
if patches != 1 || cfg["ttl"].(float64) != 20 || cfg["loop_wait"].(float64) != 5 || cfg["retry_timeout"].(float64) != 5 {
t.Errorf("patched %d times to %v", patches, cfg)
}
// Patroni's own rule, or it refuses them.
if loopWait+2*retryTimeout > leaderTTL {
t.Error("loop_wait + 2*retry_timeout is more than ttl")
}
if c := Patroni(pg01, []Member{pg01, ctl02}, []Member{pg01, ctl02, ctl}, nil, "16", Secrets{}, false); !strings.Contains(c, "ttl: 20\n loop_wait: 5\n retry_timeout: 5\n") {
t.Errorf("a new cluster bootstraps with others:\n%s", c)
}
}
+3
View File
@@ -131,6 +131,9 @@ func (s *Setup) Run(ctx context.Context) (string, error) {
if err := s.memory(ctx, sec); err != nil {
return "", fmt.Errorf("memory: %w", err)
}
if err := s.timings(ctx, sec); err != nil {
return "", fmt.Errorf("failover: %w", err)
}
if err := s.database(ctx); err != nil {
return "", fmt.Errorf("database: %w", err)
}
+60
View File
@@ -0,0 +1,60 @@
package pgha
import (
"context"
"fmt"
)
// How soon a lost primary is replaced. Patroni's own defaults — the leader
// key lives 30 seconds, checked every 10 — had the control planes wait the
// better part of a minute for a store after a database machine lost its
// power. Patroni asks that loop_wait + 2*retry_timeout <= ttl, so a lost
// primary is replaced within ttl and a slow etcd round is not mistaken for
// one.
const (
leaderTTL = 20
loopWait = 5
retryTimeout = 5
)
// timingSettings are the shared configuration's keys for them.
func timingSettings() map[string]any {
return map[string]any{"ttl": leaderTTL, "loop_wait": loopWait, "retry_timeout": retryTimeout}
}
// timings puts them in the cluster's shared configuration, through
// Patroni's API: a cluster set up with the old ones gets these in place.
func (s *Setup) timings(ctx context.Context, sec Secrets) error {
// As with TLS, changed in place by the machine with the authority: init
// has it, and a new cluster has these from its bootstrap already.
if s.CA == nil {
return nil
}
var lastErr error
for _, d := range s.DB {
cfg, err := getConfig(ctx, d.IP)
if err != nil {
lastErr = err
continue
}
want := timingSettings()
same := true
for k, v := range want {
// JSON numbers come back as float64.
if n, ok := cfg[k].(float64); !ok || int(n) != v.(int) {
same = false
}
}
if same {
s.emit("failover", false, "a lost primary is replaced within %d seconds", leaderTTL)
return nil
}
if err := patchConfig(ctx, d.IP, sec.RestAPI, want); err != nil {
return fmt.Errorf("Patroni on %s: %w", d.IP, err)
}
s.emit("failover", true, "a lost primary is replaced within %d seconds (ttl %d, loop_wait %d, retry_timeout %d)",
leaderTTL, leaderTTL, loopWait, retryTimeout)
return nil
}
return fmt.Errorf("no member's Patroni answered: %w", lastErr)
}
+1 -1
View File
@@ -111,7 +111,7 @@ func (r *Router) isCanaryTarget(name string) bool {
// hiddenModel is a model clients cannot name: a judge, or a canary.
func (r *Router) hiddenModel(name string) bool {
return r.isGuardModel(name) || r.isCanaryTarget(name)
return r.isGuardModel(name) || r.isCanaryTarget(name) || r.modelMap()[name].IsEnvironment()
}
// setVariantHeader tells the client which side served it.
+19
View File
@@ -51,6 +51,25 @@ type Info struct {
// Workflow is the ComfyUI workflow sent for an endpoint whose engine is
// EngineComfyUI: a built-in's name or the workflow's JSON.
Workflow string
// Comfy is, for a shared ComfyUI replica that answers for several
// models, each one's workflow and checkpoint, by every name it goes by.
// Nil for a replica of one model, which Workflow and UpstreamModel say.
Comfy map[string]ComfyRoute
}
// ComfyRoute is what a request for one model of a shared ComfyUI replica is
// sent.
type ComfyRoute struct {
Workflow string
Checkpoint string
}
// ComfyFor is the workflow and checkpoint a request for model is sent here.
func (i Info) ComfyFor(model string) (workflow, checkpoint string) {
if r, ok := i.Comfy[model]; ok {
return r.Workflow, r.Checkpoint
}
return i.Workflow, i.UpstreamModel
}
// EngineExternal marks an endpoint that is a provider outside the cluster.
+19 -3
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"net/http"
"slices"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
@@ -20,8 +21,16 @@ import (
const (
purposeBatch = "batch"
purposeBatchOutput = "batch_output"
// purposeVision and purposeUserData are files a request works from — a
// picture to make a mesh of or to look at, a workflow's input — named
// by id rather than sent as base64 in every call. OpenAI's names.
purposeVision = "vision"
purposeUserData = "user_data"
)
// uploadPurposes are what a client may upload a file for.
var uploadPurposes = []string{purposeBatch, purposeVision, purposeUserData}
func newID(prefix string) string {
var b [12]byte
_, _ = rand.Read(b[:])
@@ -99,9 +108,11 @@ func (r *Router) handleUploadFile(w http.ResponseWriter, req *http.Request) {
fmt.Sprintf("a multipart form with purpose and file, at most %d MB: %v", maxBytes>>20, err))
return
}
if purpose := req.FormValue("purpose"); purpose != purposeBatch {
purpose := req.FormValue("purpose")
if !slices.Contains(uploadPurposes, purpose) {
writeError(w, http.StatusBadRequest, "invalid_request_error",
fmt.Sprintf("purpose %q: this gateway keeps files for batches only (purpose batch)", purpose))
fmt.Sprintf("purpose %q: this gateway keeps files for batches (batch) and for requests to work from "+
"(vision, user_data)", purpose))
return
}
file, header, err := req.FormFile("file")
@@ -120,8 +131,13 @@ func (r *Router) handleUploadFile(w http.ResponseWriter, req *http.Request) {
fmt.Sprintf("the file is larger than %d MB", maxBytes>>20))
return
}
f := store.File{ID: newID("file-"), Owner: ownerOf(p), Purpose: purposeBatch,
f := store.File{ID: newID("file-"), Owner: ownerOf(p), Purpose: purpose,
Filename: header.Filename, Bytes: int64(len(content)), CreatedAt: time.Now()}
// A file to work from is kept as long as what is made from it.
if purpose != purposeBatch {
_, _, _, days := r.batchCfg()
f.ExpiresAt = f.CreatedAt.AddDate(0, 0, days)
}
if err := r.store.PutFile(req.Context(), f, content); err != nil {
writeError(w, http.StatusInternalServerError, "server_error", err.Error())
return
+50
View File
@@ -269,3 +269,53 @@ func TestSignInThroughAProviderNeedsNoAPIKey(t *testing.T) {
t.Errorf("%d %s", rec.Code, rec.Body.String())
}
}
// One replica of the judge hangs: the other, on another machine, is asked
// at half the budget and answers within it, rather than the hung one taking
// the whole budget and the request being refused. Two on one machine are
// not both asked: they are slow together.
func TestAJudgeThatHangsIsOvertakenByAnother(t *testing.T) {
for _, apart := range []bool{true, false} {
hung, quick := newFakeEngine(t, "judge"), newFakeEngine(t, "judge")
hung.hang.Store(true)
quickURL := quick.srv.URL
if apart {
// Another host name for the same server: another machine, to
// the router.
quickURL = strings.Replace(quickURL, "127.0.0.1", "localhost", 1)
}
cfg := baseConfig(config.Endpoint{ID: "hung", URL: hung.srv.URL, Models: []string{"judge"}},
config.Endpoint{ID: "quick", URL: quickURL, Models: []string{"judge"}})
judge := catModel("judge", nil)
judge.Role, judge.Guard = catalog.RoleGuard, &catalog.GuardModel{Format: "qwen3guard"}
cfg.Models = []catalog.Model{judge}
rt := newTestRouter(t, cfg)
deadline := time.Now().Add(3 * time.Second)
for len(rt.reg.Snapshots("judge")) < 2 && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
}
if n := len(rt.reg.Snapshots("judge")); n != 2 {
t.Fatalf("%d replicas of the judge", n)
}
body := []byte(`{"model":"judge","messages":[{"role":"user","content":"x"}]}`)
for i := 0; i < 12 && hung.calls.Load() == 0; i++ {
before := quick.calls.Load()
ctx, cancel := context.WithTimeout(t.Context(), time.Second)
start := time.Now()
out, err := rt.Judge(ctx, "judge", "/v1/chat/completions", body)
cancel()
if hung.calls.Load() == 0 {
continue // the quick one was asked first
}
if apart && (err != nil || !strings.Contains(string(out), `"hi"`) || time.Since(start) > 900*time.Millisecond) {
t.Fatalf("apart: no verdict in time (%s): %v", time.Since(start), err)
}
if !apart && quick.calls.Load() != before {
t.Error("together: the other replica on the same machine was asked as well")
}
}
if hung.calls.Load() == 0 {
t.Fatal("the hung replica was never asked first")
}
}
}
+11 -2
View File
@@ -359,7 +359,9 @@ func (r *Router) Handler() http.Handler {
}
if r.auth == nil {
return r.refuseWhileStopped(r.refuseLoops(mux))
h := r.refuseWhileStopped(r.refuseLoops(mux))
r.selfHandler.Store(&h)
return h
}
exempt := []string{"/health", "/ready", "/leader", "/v1/openapi.json", "/", "/about", "/_static/", "/_lang/", "/claim/",
"/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/",
@@ -373,7 +375,9 @@ func (r *Router) Handler() http.Handler {
// Wrapping the whole mux rather than checking inside each handler: adding a
// route must not be able to add an unauthenticated one by omission. Health
// stays open because a load balancer has no key.
return r.refuseWhileStopped(r.refuseLoops(auth.Middleware(r.auth, r.log, exempt...)(mux)))
h := r.refuseWhileStopped(r.refuseLoops(auth.Middleware(r.auth, r.log, exempt...)(mux)))
r.selfHandler.Store(&h)
return h
}
// refuseWhileStopped answers every request for a model with 503 while the
@@ -470,6 +474,11 @@ type parsedRequest struct {
// internal is a request the gateway makes of itself — a judge's — which
// no guard filters.
internal bool
// avoid are endpoints this request is not sent to while another will do,
// and picked is told the endpoint it is sent to: a judge asked twice
// asks two replicas.
avoid []string
picked func(endpointID string)
// cache is where its answer is kept, when its project reuses answers
// and this one may be.
cache cacheState
+2 -1
View File
@@ -182,7 +182,8 @@ func (r *Router) generateImages(ctx context.Context, w http.ResponseWriter, req
Model string `json:"model"`
}
_ = json.Unmarshal(body, &upstream)
tpl, err := comfy.Load(ep.Workflow)
wf, _ := ep.ComfyFor(req.Model)
tpl, err := comfy.Load(wf)
if err != nil {
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
}
+29
View File
@@ -360,3 +360,32 @@ func TestAStandbyLeavesJobsToTheLeader(t *testing.T) {
t.Errorf("the leader did not serve it: %v", got)
}
}
func TestAJobWaitingForTheCardDoesNotHoldUpAnotherModelsJob(t *testing.T) {
// One at a time, an sf3d job waiting for the card held up a job for a
// model that could have run.
c := newJobComfy(t)
rt, secret := jobRouter(t, c)
rt.cfg.ActivationWait = 10 * time.Millisecond
rt.SetActivator(&waitingActivator{fakeActivator: fakeActivator{onDemand: map[string]bool{"sleepy": true}},
heldBy: "flux", eta: time.Hour})
now := time.Now()
put := func(id, model, body string, at time.Time) {
if err := rt.store.PutJob(t.Context(), store.Job{ID: id, Owner: "project:" + mustProjectOf(t, rt, "key-1"),
KeyID: "key-1", Endpoint: imagesPath, Model: model, Input: []byte(body), Status: store.JobQueued,
CreatedAt: at, ExpiresAt: at.Add(jobWindow)}); err != nil {
t.Fatal(err)
}
}
put("job_wait", "sleepy", `{"model":"sleepy","prompt":"a cube"}`, now.Add(-time.Minute))
put("job_go", "flux", `{"model":"flux","prompt":"a cube"}`, now)
rt.wakeJobs()
got := waitJob(t, rt.Handler(), secret, "job_go", jobDone)
if got["status"] != "completed" {
t.Errorf("the other model's job: %v", got)
}
if w, _ := rt.store.JobByID(t.Context(), "job_wait"); w.Status != store.JobRunning {
t.Errorf("the waiting job is %s", w.Status)
}
}
+38 -2
View File
@@ -10,6 +10,7 @@ import (
"os"
"strconv"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
@@ -27,6 +28,10 @@ const (
jobCancelCheck = 2 * time.Second
// jobMaxResult bounds what one job may answer: a mesh, a short video.
jobMaxResult = 256 << 20
// jobsPerModel is how many of one model's jobs run at once: its engine
// works through them in turn anyway. jobsAtOnce bounds them all.
jobsPerModel = 1
jobsAtOnce = 8
)
// jobUnplacedGrace is how long a job keeps asking for a model said to fit
@@ -52,10 +57,28 @@ func (r *Router) runJobs(ctx context.Context) {
owner := fmt.Sprintf("%s:%d:%s", host, os.Getpid(), newID(""))
t := time.NewTicker(jobPoll)
defer t.Stop()
// Jobs run side by side, one per model at a time and jobsAtOnce in all:
// one at a time, a job waiting for the card held up another model's job
// that could have run.
var mu sync.Mutex
running := map[string]int{}
total := 0
for {
for r.leading() {
mu.Lock()
if total >= jobsAtOnce {
mu.Unlock()
break
}
var skip []string
for m, n := range running {
if n >= jobsPerModel {
skip = append(skip, m)
}
}
mu.Unlock()
now := time.Now()
j, ok, err := r.store.ClaimJob(ctx, owner, now, now.Add(jobLease))
j, ok, err := r.store.ClaimJob(ctx, owner, now, now.Add(jobLease), skip...)
if err != nil {
if ctx.Err() == nil {
r.log.Warn("could not look for jobs to serve", "err", err)
@@ -65,7 +88,20 @@ func (r *Router) runJobs(ctx context.Context) {
if !ok {
break
}
r.workJob(ctx, owner, j)
mu.Lock()
running[j.Model]++
total++
mu.Unlock()
go func(j store.Job) {
r.workJob(ctx, owner, j)
mu.Lock()
if running[j.Model]--; running[j.Model] <= 0 {
delete(running, j.Model)
}
total--
mu.Unlock()
r.wakeJobs() // room for the next
}(j)
}
// A job's record outlives its file by nothing: both go at the
// retention.
+99 -1
View File
@@ -5,6 +5,9 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/guard"
)
@@ -18,6 +21,70 @@ func (r *Router) Judge(ctx context.Context, model, path string, body []byte) ([]
if path != "/v1/chat/completions" && path != "/v1/completions" {
return nil, fmt.Errorf("a judge is asked at /v1/chat/completions or /v1/completions, not %s", path)
}
deadline, ok := ctx.Deadline()
canonical, _ := r.resolve(model)
if !ok || len(r.reg.Snapshots(canonical)) < 2 {
return r.judgeOnce(ctx, path, body, nil, nil)
}
// Asked of a second replica too when the first has not answered by half
// the budget, and the first answer taken. One replica that hung — busy,
// or on a machine that is going — took the whole budget, and the retry
// on the other found none left: the request was refused (fail: closed)
// though a judge stood idle. The first is not cut short, which on a slow
// machine would refuse what it was about to answer.
//
// Only of a replica on another machine. Two on one machine whose CPUs
// are taken are slow together, and asking both doubled the work there:
// measured, as many requests failed as before, more of them refused.
ctx, cancel := context.WithCancel(ctx)
defer cancel()
type answer struct {
out []byte
err error
}
answers := make(chan answer, 2)
var mu sync.Mutex
var first string
go func() {
out, err := r.judgeOnce(ctx, path, body, nil, func(id string) {
mu.Lock()
first = id
mu.Unlock()
})
answers <- answer{out, err}
}()
hedge := time.NewTimer(time.Until(deadline) / 2)
defer hedge.Stop()
asking := 1
var lastErr error
for asking > 0 {
select {
case a := <-answers:
asking--
if a.err == nil {
return a.out, nil
}
lastErr = a.err
case <-hedge.C:
mu.Lock()
avoid := []string{first}
mu.Unlock()
if !r.elsewhere(canonical, first) {
continue
}
asking++
go func() {
out, err := r.judgeOnce(ctx, path, body, avoid, nil)
answers <- answer{out, err}
}()
}
}
return nil, lastErr
}
// judgeOnce asks one judge request of the router, as it would a client's.
func (r *Router) judgeOnce(ctx context.Context, path string, body []byte, avoid []string, picked func(string)) ([]byte, error) {
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, path, bytes.NewReader(body))
if err != nil {
return nil, err
@@ -30,13 +97,14 @@ func (r *Router) Judge(ctx context.Context, model, path string, body []byte) ([]
req.Stream = false
req.RequestID = requestID("")
req.Header = http.Header{"X-Request-Id": []string{req.RequestID}}
req.avoid, req.picked = avoid, picked
w := &bufferWriter{header: http.Header{}}
res := r.dispatch(ctx, w, req)
if res.err != nil {
return nil, res.err
}
if w.status != 0 && w.status != http.StatusOK {
return nil, fmt.Errorf("the judge %s answered %d", model, w.status)
return nil, fmt.Errorf("the judge %s answered %d", req.Model, w.status)
}
return w.buf.Bytes(), nil
}
@@ -84,3 +152,33 @@ func (b *bufferWriter) Write(p []byte) (int, error) {
return b.buf.Write(p)
}
func (b *bufferWriter) Flush() {}
// elsewhere is whether a model has a replica on another machine than this
// endpoint's, by the host of its address.
func (r *Router) elsewhere(model, endpointID string) bool {
snaps := r.reg.Snapshots(model)
host := ""
for _, e := range snaps {
if e.ID == endpointID {
host = hostOfURL(e.URL)
}
}
if host == "" {
return false
}
for _, e := range snaps {
if e.ID != endpointID && hostOfURL(e.URL) != host {
return true
}
}
return false
}
// hostOfURL is an address's host, without its port.
func hostOfURL(raw string) string {
u, err := url.Parse(raw)
if err != nil {
return ""
}
return u.Hostname()
}
+980
View File
@@ -0,0 +1,980 @@
package router
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"image"
"image/color"
_ "image/jpeg" // a picture sent to look at may be a JPEG
"image/png"
"net/http"
"slices"
"strconv"
"strings"
"sync"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// --- MCP: pictures, 3D models, looking at pictures, workflows ---
//
// The gateway's own models offered as tools, so an agent — Claude Code
// driving Blender, say — makes a concept, a mesh from it and has a render
// looked at without a tool server of its own. Each tool makes its request
// of the gateway as the caller would, under the caller's key: the same
// allowlist, limits, licences left out, guardrails and accounting. What it
// makes is kept as a file of the caller's, said by id and by the address
// it is fetched from with the same key, since a tool server over HTTP
// cannot write to the caller's disk.
const (
// mcpBody bounds a tool call: up to four pictures to look at, as base64.
mcpBody = 64 << 20
// previewEdge is the long side of the picture shown to the agent beside
// the file: enough to see it, not a megapixel of tokens.
previewEdge = 512
// lookEdge is the long side of a picture sent to a vision model:
// 1280x720 is about 940 tokens.
lookEdge = 1280
// mcpWaitStreamed is how long a call answered as a stream waits for its
// job, saying how far along it is meanwhile; mcpWaitPlain how long one
// answered in one piece does, under the minute a proxy allows.
mcpWaitStreamed = 10 * time.Minute
mcpWaitPlain = 50 * time.Second
mcpWaitMax = 15 * time.Minute
mcpPoll = 2 * time.Second
)
// The prompt endings of generate_image's styles: what a picture a mesh is
// made from wants, and a surface that tiles.
var imageStyles = map[string]string{
"none": "",
"concept": ", single object centered, orthographic three-quarter view, flat even studio lighting, no shadows, " +
"no background elements, plain flat grey background, clean silhouette, game asset concept, high detail, no text, no watermark",
"side": ", single object, exact side view (profile), orthographic, flat even studio lighting, no shadows, " +
"plain flat grey background, clean silhouette, game asset concept, high detail, no text, no watermark",
"texture": ", seamless tileable texture, top-down orthographic, flat even lighting, no shadows, no perspective, no text",
}
// mcpMediaInstructions is said to an agent when it connects and the gateway
// has something to make pictures or 3D models with.
const mcpMediaInstructions = "Pictures, 3D models and a model that looks at pictures, on this gateway's own GPUs. " +
"A typical loop: generate_image (style concept) -> generate_mesh from its file_id -> import the GLB -> render -> " +
"review_images against the concept -> fix. Results are files: fetch them from their url with your API key. " +
"AI meshes suit rocks, debris and background props; hard-surface hero assets are usually better built by hand " +
"from the concept. A GPU holds one of these models at a time and switching costs a cold start, so batch calls " +
"of the same kind; gateway_status says which is loaded."
// mediaModels are the gateway's models by what they do, by name, deduplicated
// from the aliases that also resolve to them.
type mediaModels struct{ images, meshes, vision, workflows []string }
func (r *Router) mediaModels() mediaModels {
var out mediaModels
seen := map[string]bool{}
for _, m := range r.modelMap() {
if seen[m.Name] || m.IsGuard() {
continue
}
seen[m.Name] = true
if m.MakesImages() {
out.images = append(out.images, m.Name)
}
if m.MakesMeshes() {
out.meshes = append(out.meshes, m.Name)
}
if m.Capabilities.Vision {
out.vision = append(out.vision, m.Name)
}
if len(m.ComfyInputs()) > 0 {
out.workflows = append(out.workflows, m.Name)
}
}
for _, l := range [][]string{out.images, out.meshes, out.vision, out.workflows} {
slices.Sort(l)
}
return out
}
func (mm mediaModels) any() bool {
return len(mm.images)+len(mm.meshes)+len(mm.vision)+len(mm.workflows) > 0
}
// defaultOf is the model a tool uses when the call names none: the one the
// tool is best known with when the gateway has it, else the first.
func defaultOf(models []string, preferred ...string) string {
for _, p := range preferred {
if slices.Contains(models, p) {
return p
}
}
if len(models) == 0 {
return ""
}
return models[0]
}
func enumOrNone(models []string) map[string]any {
s := map[string]any{"type": "string"}
if len(models) > 0 {
s["enum"] = models
}
return s
}
// mediaTools are the tools for what the gateway's models can do: none for
// what it has no model for.
func (r *Router) mediaTools() []map[string]any {
mm := r.mediaModels()
var tools []map[string]any
fileArg := map[string]any{"type": "string",
"description": "A picture: a /v1/files id (file-…), a data URL, or base64"}
wait := map[string]any{"type": "integer", "minimum": 0, "maximum": int(mcpWaitMax.Seconds()),
"description": "Most seconds to wait for it before answering with the job to ask after (job_status)"}
if len(mm.images) > 0 {
tools = append(tools, map[string]any{
"name": "generate_image",
"description": "Make a picture from a prompt (~15 s warm, longer when the GPU must switch models). " +
"Keeps the PNG as a file and answers its file_id, url and seed, with a small preview. " +
"style: 'concept' (one object, 3/4 view, grey background: good for generate_mesh), 'side' " +
"(exact side view: best meshes for vehicles), 'texture' (seamless tileable surface), 'none' (the prompt as is).",
"inputSchema": map[string]any{
"type": "object",
"required": []string{"prompt"},
"properties": map[string]any{
"prompt": map[string]any{"type": "string"},
"style": map[string]any{"type": "string", "enum": []string{"none", "concept", "side", "texture"}, "default": "none"},
"size": map[string]any{"type": "integer", "description": "Square side in px, a multiple of 16, 256-2048", "default": 1024},
"seed": map[string]any{"type": "integer"},
"negative_prompt": map[string]any{"type": "string"},
"model": enumOrNone(mm.images),
},
},
})
}
if len(mm.meshes) > 0 {
tools = append(tools, map[string]any{
"name": "generate_mesh",
"description": "Make a 3D model (GLB) from a picture of one object on a plain background — the gateway " +
"cuts the background out itself — or from a prompt, for which a concept picture is made first. " +
"Takes 30 s to a few minutes; waits for it, saying how far along it is when the client takes a stream. " +
"Answers the GLB's file_id and url, and its licence. The mesh is about 1 m across, its orientation arbitrary.",
"inputSchema": map[string]any{
"type": "object",
"properties": map[string]any{
"image": fileArg,
"prompt": map[string]any{"type": "string", "description": "Used when no image is given"},
"model": enumOrNone(mm.meshes),
"seed": map[string]any{"type": "integer"},
"resolution": map[string]any{"type": "integer", "description": "The model's own grid, a multiple of 32 (TripoSR: 256 is enough)"},
"texture_resolution": map[string]any{"type": "integer", "minimum": 512, "maximum": 2048,
"description": "For a model that textures its mesh (sf3d): the texture's side in px"},
"inputs": map[string]any{"type": "object",
"description": "Any other input the model's workflow declares (gateway_status lists them): remesh, vertex_count, foreground_ratio, albedo_match…"},
"wait_seconds": wait,
},
},
})
}
if len(mm.vision) > 0 {
tools = append(tools, map[string]any{
"name": "review_images",
"description": "Ask a vision model about 1-4 pictures — a render beside its concept, say. A cheap critic " +
"for render-and-fix loops. json_schema makes it answer strict JSON. A cold start can take minutes " +
"when the GPU was making pictures.",
"inputSchema": map[string]any{
"type": "object",
"required": []string{"images", "question"},
"properties": map[string]any{
"images": map[string]any{"type": "array", "items": fileArg, "minItems": 1, "maxItems": 4},
"question": map[string]any{"type": "string"},
"json_schema": map[string]any{"type": "object", "description": "JSON Schema the answer must follow"},
"model": enumOrNone(mm.vision),
},
},
})
}
if len(mm.workflows) > 0 {
tools = append(tools, map[string]any{
"name": "run_workflow",
"description": "Run a ComfyUI workflow the gateway serves, with the inputs its catalog entry declares " +
"(gateway_status lists them). A file input is a /v1/files id, a data URL or base64. Answers its result file.",
"inputSchema": map[string]any{
"type": "object",
"required": []string{"model", "inputs"},
"properties": map[string]any{
"model": enumOrNone(mm.workflows),
"inputs": map[string]any{"type": "object"},
"wait_seconds": wait,
},
},
})
}
if mm.any() {
tools = append(tools,
map[string]any{
"name": "job_status",
"description": "Where a 3D model or workflow run stands, and its result file when done. wait_seconds waits for it to finish.",
"inputSchema": map[string]any{
"type": "object",
"required": []string{"job_id"},
"properties": map[string]any{"job_id": map[string]any{"type": "string"}, "wait_seconds": wait},
},
},
map[string]any{
"name": "gateway_status",
"description": "The gateway's picture, 3D, vision and workflow models: whether each is loaded on a GPU now, " +
"and a workflow's inputs; and your last few jobs. Use it to batch calls and avoid cold starts.",
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{}},
})
}
return tools
}
// mediaCall is one tool call: who made it, and how to tell them how far it
// has got.
type mediaCall struct {
req *http.Request
progress func(percent float64, message string)
// streamed is a call whose answer is a stream, which may wait longer.
streamed bool
}
func (c mediaCall) wait(asked *int) time.Duration {
d := mcpWaitPlain
if c.streamed {
d = mcpWaitStreamed
}
if asked != nil {
d = min(time.Duration(*asked)*time.Second, mcpWaitMax)
}
return d
}
// isMediaTool is whether a tool is one of these.
func isMediaTool(name string) bool {
switch name {
case "generate_image", "generate_mesh", "review_images", "run_workflow", "job_status", "gateway_status":
return true
}
return false
}
// callMediaTool runs one of the tools. As with the notes, a failure is a
// tool result with isError set, which the model gets to read.
func (r *Router) callMediaTool(c mediaCall, name string, args json.RawMessage) map[string]any {
var res map[string]any
var err error
switch name {
case "generate_image":
res, err = r.toolGenerateImage(c, args)
case "generate_mesh":
res, err = r.toolGenerateMesh(c, args)
case "review_images":
res, err = r.toolReviewImages(c, args)
case "run_workflow":
res, err = r.toolRunWorkflow(c, args)
case "job_status":
res, err = r.toolJobStatus(c, args)
case "gateway_status":
res, err = r.toolGatewayStatus(c)
default:
err = errors.New("unknown tool " + name)
}
if err != nil {
return toolText(err.Error(), true)
}
return res
}
// self makes a request of the gateway's client API as the caller: their key,
// their agent name, and the context the call was made in.
func (r *Router) self(c mediaCall, method, path string, body any) (int, http.Header, []byte, error) {
h := r.selfHandler.Load()
if h == nil {
return 0, nil, nil, errors.New("the gateway's API is not mounted here")
}
var rd *bytes.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return 0, nil, nil, err
}
rd = bytes.NewReader(b)
} else {
rd = bytes.NewReader(nil)
}
out, err := http.NewRequestWithContext(c.req.Context(), method, path, rd)
if err != nil {
return 0, nil, nil, err
}
for _, k := range []string{"Authorization", "X-Api-Key", agentHeader, "X-Forwarded-For", "X-Real-Ip"} {
if v := c.req.Header.Get(k); v != "" {
out.Header.Set(k, v)
}
}
out.RemoteAddr = c.req.RemoteAddr
if body != nil {
out.Header.Set("Content-Type", "application/json")
}
// A picture or a mesh comes back whole: past the 1 MiB a judge's
// answer is held to.
w := &bufferWriter{header: http.Header{}, max: mcpBody * 2}
(*h).ServeHTTP(w, out)
status := w.status
if status == 0 {
status = http.StatusOK
}
return status, w.header, w.buf.Bytes(), nil
}
// selfJSON is self for an answer in JSON, a failure said as the API said it.
func (r *Router) selfJSON(c mediaCall, method, path string, body, into any) (http.Header, error) {
status, h, b, err := r.self(c, method, path, body)
if err != nil {
return nil, err
}
if status >= 300 {
return h, fmt.Errorf("%s %s: %d %s", method, path, status, errorMessage(b))
}
if into == nil {
return h, nil
}
return h, json.Unmarshal(b, into)
}
// selfJSONWaiting is selfJSON for a model that may be cold: an answer that
// says to come back — a model loading, another holding the GPU, a full
// queue, all with Retry-After — is waited out for as long as the call may
// wait, saying so meanwhile. One that does not say when, a spent budget, is
// not: waiting would not help.
func (r *Router) selfJSONWaiting(c mediaCall, model, path string, body, into any) (http.Header, error) {
deadline := time.Now().Add(c.wait(nil))
for {
status, h, b, err := r.self(c, http.MethodPost, path, body)
if err != nil {
return nil, err
}
retry, _ := strconv.Atoi(h.Get("Retry-After"))
if status < 300 {
return h, json.Unmarshal(b, into)
}
if retry <= 0 || (status != http.StatusTooManyRequests && status < 500) || !time.Now().Before(deadline) {
msg := fmt.Sprintf("POST %s: %d %s", path, status, errorMessage(b))
if retry > 0 {
msg += "; still not ready after waiting — call again, or ask gateway_status which model holds the GPU"
}
return h, errors.New(msg)
}
say := model + " is loading"
if held := h.Get(waitingForHeader); held != "" {
say = model + " is waiting for " + held + " to give up the GPU"
if eta := h.Get(etaHeader); eta != "" {
say += ", about " + eta + " s"
}
}
c.progress(5, say)
select {
case <-c.req.Context().Done():
return nil, c.req.Context().Err()
case <-time.After(min(time.Duration(retry)*time.Second, 10*time.Second, time.Until(deadline)+time.Second)):
}
}
}
// fileURL is where a file is fetched from, with the caller's key, as the
// caller reached the gateway.
func fileURL(req *http.Request, id string) string {
scheme := "http"
if req.TLS != nil {
scheme = "https"
}
if p := req.Header.Get("X-Forwarded-Proto"); p != "" {
scheme = strings.TrimSpace(strings.Split(p, ",")[0])
}
host := req.Host
if h := req.Header.Get("X-Forwarded-Host"); h != "" {
host = strings.TrimSpace(strings.Split(h, ",")[0])
}
return scheme + "://" + host + "/v1/files/" + id + "/content"
}
// keepFile keeps what a tool made as the caller's file, for as long as a
// job's result is kept.
func (r *Router) keepFile(c mediaCall, name string, content []byte) (store.File, error) {
p, ok := auth.FromContext(c.req.Context())
if !ok || r.store == nil {
return store.File{}, errors.New("this gateway keeps no files (it runs without authentication or a store)")
}
if r.noStore(callerSubject(c.req)) {
return store.File{}, errors.New("the gateway's policy for this caller keeps nothing, so no file is kept")
}
_, _, _, days := r.batchCfg()
now := time.Now()
f := store.File{ID: newID("file-"), Owner: ownerOf(p), Purpose: purposeJobOutput, Filename: name,
Bytes: int64(len(content)), CreatedAt: now, ExpiresAt: now.AddDate(0, 0, days)}
return f, r.store.PutFile(c.req.Context(), f, content)
}
// picture reads a picture argument: a file of the caller's by id, a data URL,
// or base64.
func (r *Router) picture(c mediaCall, s string) ([]byte, error) {
s = strings.TrimSpace(s)
switch {
case s == "":
return nil, errors.New("no picture given")
case strings.HasPrefix(s, "file-"):
p, ok := auth.FromContext(c.req.Context())
if !ok || r.store == nil {
return nil, errors.New("this gateway keeps no files: send the picture as base64 or a data URL")
}
f, err := r.store.FileByID(c.req.Context(), s)
if errors.Is(err, store.ErrNotFound) || err == nil && f.Owner != ownerOf(p) {
return nil, fmt.Errorf("no file %s of yours", s)
}
if err != nil {
return nil, err
}
return r.store.FileContent(c.req.Context(), s)
case strings.HasPrefix(s, "data:"):
_, after, ok := strings.Cut(s, ";base64,")
if !ok {
return nil, errors.New("a data URL without base64 in it")
}
s = after
}
b, err := base64.StdEncoding.DecodeString(s)
if err != nil {
return nil, errors.New("the picture is neither a file id, a data URL nor base64")
}
return b, nil
}
func dataURL(png []byte) string {
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
}
// shrink returns a picture as a PNG whose long side is at most edge.
func shrink(pic []byte, edge int) ([]byte, error) {
src, format, err := image.Decode(bytes.NewReader(pic))
if err != nil {
return nil, fmt.Errorf("not a picture this gateway reads (PNG or JPEG): %w", err)
}
b := src.Bounds()
w, h := b.Dx(), b.Dy()
if max(w, h) <= edge {
if format == "png" {
return pic, nil
}
edge = max(w, h) // only re-encoded
}
scale := float64(edge) / float64(max(w, h))
dw, dh := max(1, int(float64(w)*scale+0.5)), max(1, int(float64(h)*scale+0.5))
dst := image.NewNRGBA(image.Rect(0, 0, dw, dh))
// Each pixel the average of the box of pixels it stands for.
for y := range dh {
y0, y1 := b.Min.Y+y*h/dh, b.Min.Y+max((y+1)*h/dh, y*h/dh+1)
for x := range dw {
x0, x1 := b.Min.X+x*w/dw, b.Min.X+max((x+1)*w/dw, x*w/dw+1)
var sr, sg, sb, sa, n uint64
for yy := y0; yy < y1; yy++ {
for xx := x0; xx < x1; xx++ {
cr, cg, cb, ca := src.At(xx, yy).RGBA()
sr, sg, sb, sa, n = sr+uint64(cr), sg+uint64(cg), sb+uint64(cb), sa+uint64(ca), n+1
}
}
// Premultiplied sums, back to straight colour.
if sa == 0 {
continue
}
dst.SetNRGBA(x, y, color.NRGBA{
R: uint8(sr * 0xff / sa), G: uint8(sg * 0xff / sa), B: uint8(sb * 0xff / sa), A: uint8(sa / n >> 8),
})
}
}
var out bytes.Buffer
if err := png.Encode(&out, dst); err != nil {
return nil, err
}
return out.Bytes(), nil
}
// --- the tools ---
func (r *Router) toolGenerateImage(c mediaCall, raw json.RawMessage) (map[string]any, error) {
var a struct {
Prompt string `json:"prompt"`
Style string `json:"style"`
NegativePrompt string `json:"negative_prompt"`
Model string `json:"model"`
Size int `json:"size"`
Seed *int64 `json:"seed"`
}
if err := decodeArgs(raw, &a); err != nil {
return nil, err
}
res, _, err := r.makeImage(c, a.Prompt, a.Style, a.NegativePrompt, a.Model, a.Size, a.Seed)
if err != nil {
return nil, err
}
return res, nil
}
// makeImage makes a picture and keeps it: the tool's answer, and the PNG.
func (r *Router) makeImage(c mediaCall, prompt, style, negative, model string, size int, seed *int64) (map[string]any, []byte, error) {
if strings.TrimSpace(prompt) == "" {
return nil, nil, errors.New("prompt is required")
}
if style == "" {
style = "none"
}
suffix, ok := imageStyles[style]
if !ok {
return nil, nil, fmt.Errorf("style %q: concept, side, texture or none", style)
}
if model == "" {
model = defaultOf(r.mediaModels().images, "flux-schnell")
}
if size == 0 {
size = 1024
}
body := map[string]any{"model": model, "prompt": prompt + suffix, "n": 1,
"size": fmt.Sprintf("%dx%d", size, size), "response_format": "b64_json"}
if seed != nil {
body["seed"] = *seed
}
if negative != "" {
body["negative_prompt"] = negative
}
c.progress(5, "making the picture with "+model)
var out struct {
Data []struct {
B64 string `json:"b64_json"`
} `json:"data"`
}
h, err := r.selfJSONWaiting(c, model, imagesPath, body, &out)
if err != nil {
return nil, nil, err
}
if len(out.Data) == 0 {
return nil, nil, errors.New(model + " made no picture")
}
pic, err := base64.StdEncoding.DecodeString(out.Data[0].B64)
if err != nil {
return nil, nil, err
}
info := map[string]any{"model": model, "width": size, "height": size}
if s, err := strconv.ParseInt(h.Get("x-llmgw-seed"), 10, 64); err == nil {
info["seed"] = s
}
if m, ok := r.modelMap()[model]; ok && m.License != nil {
info["license"] = m.License
}
content := []map[string]any{}
if f, err := r.keepFile(c, "image-"+strings.TrimPrefix(newID(""), "-")+".png", pic); err == nil {
info["file_id"], info["url"], info["bytes"] = f.ID, fileURL(c.req, f.ID), f.Bytes
} else {
// Nowhere to keep it: the picture itself, whole.
info["note"] = err.Error() + "; the picture is in this answer at full size"
content = append(content, map[string]any{"type": "image", "mimeType": "image/png", "data": out.Data[0].B64})
}
text, _ := json.Marshal(info)
content = append([]map[string]any{{"type": "text", "text": string(text)}}, content...)
if _, kept := info["file_id"]; kept {
if small, err := shrink(pic, previewEdge); err == nil {
content = append(content, map[string]any{"type": "image", "mimeType": "image/png",
"data": base64.StdEncoding.EncodeToString(small)})
}
}
return map[string]any{"content": content, "isError": false}, pic, nil
}
func (r *Router) toolGenerateMesh(c mediaCall, raw json.RawMessage) (map[string]any, error) {
var a struct {
Image, Prompt, Model string
Seed *int64
Resolution int
TextureResolution int `json:"texture_resolution"`
Inputs map[string]any
WaitSeconds *int `json:"wait_seconds"`
}
if err := decodeArgs(raw, &a); err != nil {
return nil, err
}
mm := r.mediaModels()
if a.Model == "" {
a.Model = defaultOf(mm.meshes, "sf3d", "triposr")
}
m, ok := r.modelMap()[a.Model]
if !ok || !m.MakesMeshes() {
return nil, fmt.Errorf("%q makes no 3D models here; these do: %s", a.Model, strings.Join(mm.meshes, ", "))
}
extra := map[string]any{}
var pic []byte
var err error
switch {
case a.Image != "":
if pic, err = r.picture(c, a.Image); err != nil {
return nil, err
}
case a.Prompt != "":
res, made, err := r.makeImage(c, a.Prompt, "concept", "", "", 0, nil)
if err != nil {
return nil, fmt.Errorf("the concept picture: %w", err)
}
pic = made
var info map[string]any
if json.Unmarshal([]byte(res["content"].([]map[string]any)[0]["text"].(string)), &info) == nil {
extra["concept_file_id"], extra["concept_seed"] = info["file_id"], info["seed"]
}
default:
return nil, errors.New("an image, or a prompt to make one from")
}
// A model whose workflow declares its inputs takes them at the
// workflows' path, every one it names; one that does not, the 3D path's
// seed and grid.
path, body := meshesPath, map[string]any{"model": a.Model, "image": dataURL(pic)}
if declared := m.ComfyInputs(); len(declared) > 0 {
names := map[string]string{}
var image string
for _, in := range declared {
names[in.Name] = in.Type
if image == "" && in.Type == "image" {
image = in.Name
}
}
if image == "" {
return nil, fmt.Errorf("%s's workflow takes no picture", a.Model)
}
inputs := map[string]any{image: dataURL(pic)}
// A file of the caller's goes by its id, which the API takes.
if id := strings.TrimSpace(a.Image); strings.HasPrefix(id, "file-") {
inputs[image] = id
}
for k, v := range a.Inputs {
inputs[k] = v
}
// A textured mesh's resolution is its texture's; a grid's, the grid's.
if _, ok := names["resolution"]; ok {
switch {
case a.TextureResolution > 0:
inputs["resolution"] = a.TextureResolution
case a.Resolution > 0:
inputs["resolution"] = a.Resolution
}
}
path, body = workflowsPath, map[string]any{"model": a.Model, "inputs": inputs}
if a.Seed != nil {
body["seed"] = *a.Seed
}
} else {
if a.Seed != nil {
body["seed"] = *a.Seed
}
if a.Resolution > 0 {
body["resolution"] = a.Resolution
}
}
var job struct {
ID string `json:"id"`
}
if _, err := r.selfJSON(c, http.MethodPost, path, body, &job); err != nil {
return nil, err
}
c.progress(10, "queued as "+job.ID)
return r.waitJob(c, job.ID, c.wait(a.WaitSeconds), extra)
}
func (r *Router) toolRunWorkflow(c mediaCall, raw json.RawMessage) (map[string]any, error) {
var a struct {
Model string
Inputs map[string]any
WaitSeconds *int `json:"wait_seconds"`
}
if err := decodeArgs(raw, &a); err != nil {
return nil, err
}
m, ok := r.modelMap()[a.Model]
if !ok || len(m.ComfyInputs()) == 0 {
return nil, fmt.Errorf("%q runs no workflow here; these do: %s", a.Model, strings.Join(r.mediaModels().workflows, ", "))
}
// A file input given as a file id goes as it is: the API takes those.
var job struct {
ID string `json:"id"`
}
if _, err := r.selfJSON(c, http.MethodPost, workflowsPath, map[string]any{"model": a.Model, "inputs": a.Inputs}, &job); err != nil {
return nil, err
}
c.progress(10, "queued as "+job.ID)
return r.waitJob(c, job.ID, c.wait(a.WaitSeconds), nil)
}
func (r *Router) toolJobStatus(c mediaCall, raw json.RawMessage) (map[string]any, error) {
var a struct {
JobID string `json:"job_id"`
WaitSeconds *int `json:"wait_seconds"`
}
if err := decodeArgs(raw, &a); err != nil {
return nil, err
}
if a.JobID == "" {
return nil, errors.New("job_id is required")
}
d := time.Duration(0)
if a.WaitSeconds != nil {
d = c.wait(a.WaitSeconds)
}
return r.waitJob(c, a.JobID, d, nil)
}
// jobView is the part of a job a tool says.
type jobView struct {
ID string `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
Progress json.RawMessage `json:"progress"`
OutputFileID *string `json:"output_file_id"`
ContentType *string `json:"content_type"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
License json.RawMessage `json:"license"`
}
// waitJob asks after a job until it is done or d has passed, saying how far
// along it is, and answers where it stands: its file when done.
func (r *Router) waitJob(c mediaCall, id string, d time.Duration, extra map[string]any) (map[string]any, error) {
deadline := time.Now().Add(d)
var j jobView
for {
if _, err := r.selfJSON(c, http.MethodGet, workflowsPath+"/"+id, nil, &j); err != nil {
return nil, err
}
if store.JobDone(j.Status) || !time.Now().Before(deadline) {
break
}
pct, msg := jobProgress(j)
c.progress(pct, msg)
select {
case <-c.req.Context().Done():
return nil, c.req.Context().Err()
case <-time.After(mcpPoll):
}
}
info := map[string]any{"job_id": j.ID, "model": j.Model, "status": j.Status}
for k, v := range extra {
if v != nil {
info[k] = v
}
}
if len(j.License) > 0 && string(j.License) != "null" {
info["license"] = j.License
}
switch {
case j.Status == store.JobCompleted && j.OutputFileID != nil:
info["file_id"], info["url"] = *j.OutputFileID, fileURL(c.req, *j.OutputFileID)
if j.ContentType != nil {
info["content_type"] = *j.ContentType
}
if r.store != nil {
if f, err := r.store.FileByID(c.req.Context(), *j.OutputFileID); err == nil {
info["bytes"] = f.Bytes
}
}
case store.JobDone(j.Status):
if j.Error != nil {
info["error"] = j.Error.Message
}
b, _ := json.Marshal(info)
return toolText(string(b), true), nil
default:
info["progress"] = j.Progress
info["next"] = "not done yet: call job_status with this job_id (and wait_seconds) to wait for it"
}
b, _ := json.Marshal(info)
return toolText(string(b), false), nil
}
// jobProgress reads a job's progress as a percent and a phrase.
func jobProgress(j jobView) (float64, string) {
var p struct {
State string `json:"state"`
Ahead int `json:"ahead"`
Percent float64 `json:"percent"`
Node string `json:"node_class"`
HeldBy string `json:"held_by"`
}
_ = json.Unmarshal(j.Progress, &p)
switch {
case p.State == "queued":
return 10, fmt.Sprintf("%s: queued, %d ahead", j.ID, p.Ahead)
case p.State == "waiting_gpu":
return 10, j.ID + ": waiting for the GPU, held by " + p.HeldBy
case p.State == "running":
return 10 + 0.9*p.Percent, fmt.Sprintf("%s: %s %.0f%%", j.ID, p.Node, p.Percent)
}
return 10, j.ID + ": " + j.Status
}
func (r *Router) toolReviewImages(c mediaCall, raw json.RawMessage) (map[string]any, error) {
var a struct {
Images []string
Question string
JSONSchema map[string]any `json:"json_schema"`
Model string
}
if err := decodeArgs(raw, &a); err != nil {
return nil, err
}
if len(a.Images) == 0 || len(a.Images) > 4 {
return nil, errors.New("one to four images")
}
if strings.TrimSpace(a.Question) == "" {
return nil, errors.New("question is required")
}
if a.Model == "" {
a.Model = defaultOf(r.mediaModels().vision, "vlm")
}
if a.Model == "" {
return nil, errors.New("no model here looks at pictures")
}
parts := []map[string]any{{"type": "text", "text": a.Question}}
for i, s := range a.Images {
pic, err := r.picture(c, s)
if err != nil {
return nil, fmt.Errorf("image %d: %w", i+1, err)
}
small, err := shrink(pic, lookEdge)
if err != nil {
return nil, fmt.Errorf("image %d: %w", i+1, err)
}
parts = append(parts, map[string]any{"type": "image_url", "image_url": map[string]any{"url": dataURL(small)}})
}
body := map[string]any{"model": a.Model, "messages": []map[string]any{{"role": "user", "content": parts}}}
if a.JSONSchema != nil {
body["response_format"] = map[string]any{"type": "json_schema",
"json_schema": map[string]any{"name": "answer", "schema": a.JSONSchema, "strict": true}}
}
c.progress(10, "asking "+a.Model)
var out struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if _, err := r.selfJSONWaiting(c, a.Model, "/v1/chat/completions", body, &out); err != nil {
return nil, err
}
if len(out.Choices) == 0 {
return nil, errors.New(a.Model + " gave no answer")
}
return toolText(out.Choices[0].Message.Content, false), nil
}
func (r *Router) toolGatewayStatus(c mediaCall) (map[string]any, error) {
var models struct {
Data []map[string]any `json:"data"`
}
if _, err := r.selfJSON(c, http.MethodGet, "/v1/models", nil, &models); err != nil {
return nil, err
}
var listed []map[string]any
for _, m := range models.Data {
caps, _ := m["capabilities"].(map[string]any)
if caps["images"] != true && caps["meshes"] != true && caps["vision"] != true && m["workflow"] == nil {
continue
}
e := map[string]any{"id": m["id"], "loaded": m["loaded"], "capabilities": caps}
if w := m["workflow"]; w != nil {
e["workflow"] = w
}
if l := m["license"]; l != nil {
e["license"] = l
}
listed = append(listed, e)
}
out := map[string]any{"models": listed}
var jobs struct {
Data []jobView `json:"data"`
}
if _, err := r.selfJSON(c, http.MethodGet, workflowsPath+"?limit=5", nil, &jobs); err == nil {
recent := make([]map[string]any, 0, len(jobs.Data))
for _, j := range jobs.Data {
recent = append(recent, map[string]any{"id": j.ID, "model": j.Model, "status": j.Status})
}
out["recent_jobs"] = recent
}
return toolJSON(out), nil
}
// decodeArgs reads a tool's arguments, by their JSON names: a field's own
// name in lower case unless its tag says.
func decodeArgs(raw json.RawMessage, into any) error {
if len(raw) == 0 || string(raw) == "null" {
raw = []byte("{}")
}
if err := json.Unmarshal(raw, into); err != nil {
return fmt.Errorf("the arguments: %w", err)
}
return nil
}
// streamWriter answers a tool call as server-sent events: progress
// notifications while it runs, then the answer.
type streamWriter struct {
mu sync.Mutex
w http.ResponseWriter
token json.RawMessage
}
func (s *streamWriter) write(text string) {
s.mu.Lock()
defer s.mu.Unlock()
_, _ = fmt.Fprint(s.w, text)
if f, ok := s.w.(http.Flusher); ok {
f.Flush()
}
}
func (s *streamWriter) event(v any) {
b, _ := json.Marshal(v)
s.write("event: message\ndata: " + string(b) + "\n\n")
}
// keepAlive writes a comment every while until stop is closed: a picture
// whose model starts cold is minutes of nothing said, which a proxy reads as
// a connection gone idle.
func (s *streamWriter) keepAlive(stop <-chan struct{}, every time.Duration) {
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
s.write(": still working\n\n")
}
}
}
func (s *streamWriter) progress(percent float64, message string) {
if len(s.token) == 0 {
return
}
s.event(map[string]any{"jsonrpc": "2.0", "method": "notifications/progress", "params": map[string]any{
"progressToken": s.token, "progress": percent, "total": 100, "message": message}})
}
// acceptsStream is a client that takes a POST's answer as a stream, as the
// streamable HTTP transport lets it.
func acceptsStream(req *http.Request) bool {
return strings.Contains(req.Header.Get("Accept"), "text/event-stream")
}
+371
View File
@@ -0,0 +1,371 @@
package router
import (
"bytes"
"encoding/base64"
"encoding/json"
"fmt"
"image"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
)
// mcp makes one JSON-RPC call at /mcp and answers its result; streamed, as a
// client that takes server-sent events, it answers the events' data too.
func mcp(t *testing.T, h http.Handler, secret, method string, params any, streamed bool) (map[string]any, []string) {
t.Helper()
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(b))
req.Header.Set("Content-Type", "application/json")
if secret != "" {
req.Header.Set("Authorization", "Bearer "+secret)
}
if streamed {
req.Header.Set("Accept", "application/json, text/event-stream")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("%s: %d %s", method, rec.Code, rec.Body.String())
}
body := rec.Body.String()
var events []string
if streamed {
if ct := rec.Header().Get("Content-Type"); ct != "text/event-stream" {
t.Fatalf("a streamed call answered %s: %s", ct, body)
}
for _, line := range strings.Split(body, "\n") {
if d, ok := strings.CutPrefix(line, "data: "); ok {
events = append(events, d)
}
}
if len(events) == 0 {
t.Fatalf("no events: %s", body)
}
body = events[len(events)-1]
}
var resp struct {
Result map[string]any `json:"result"`
Error any `json:"error"`
}
if err := json.Unmarshal([]byte(body), &resp); err != nil || resp.Error != nil {
t.Fatalf("%s: %v %s", method, err, body)
}
return resp.Result, events
}
func toolNames(res map[string]any) []string {
var names []string
for _, tl := range res["tools"].([]any) {
names = append(names, tl.(map[string]any)["name"].(string))
}
return names
}
// resultInfo is a tool result's first text, as the JSON it holds.
func resultInfo(t *testing.T, res map[string]any) map[string]any {
t.Helper()
if res["isError"] == true {
t.Fatalf("the tool failed: %v", res["content"])
}
var info map[string]any
text := res["content"].([]any)[0].(map[string]any)["text"].(string)
if err := json.Unmarshal([]byte(text), &info); err != nil {
t.Fatalf("not JSON: %s", text)
}
return info
}
func TestTheMCPToolsAreWhatTheGatewaysModelsCanDo(t *testing.T) {
c := newMeshComfy(t)
rt, secret := meshRouter(t, c)
h := rt.Handler()
init, _ := mcp(t, h, secret, "initialize", map[string]any{"protocolVersion": "2025-06-18"}, false)
if init["protocolVersion"] != "2025-06-18" || !strings.Contains(init["instructions"].(string), "batch calls") {
t.Errorf("initialize: %v", init)
}
list, _ := mcp(t, h, secret, "tools/list", map[string]any{}, false)
names := strings.Join(toolNames(list), ",")
for _, want := range []string{"remember", "generate_image", "generate_mesh", "job_status", "gateway_status"} {
if !strings.Contains(names, want) {
t.Errorf("no %s in %s", want, names)
}
}
// Nothing here looks at pictures or declares a workflow's inputs.
if strings.Contains(names, "review_images") || strings.Contains(names, "run_workflow") {
t.Errorf("tools for models the gateway has not: %s", names)
}
// A gateway with only text models offers the notes alone.
plain := newTestRouter(t, baseConfig()).Handler()
list, _ = mcp(t, plain, "", "tools/list", map[string]any{}, false)
if got := strings.Join(toolNames(list), ","); got != "remember,recall,forget" {
t.Errorf("a text-only gateway offers %s", got)
}
}
func TestAMeshIsMadeThroughMCPAndKeptAsTheCallersFile(t *testing.T) {
c := newMeshComfy(t)
rt, secret := meshRouter(t, c)
h := rt.Handler()
args := map[string]any{"model": "trellis2", "image": "data:image/png;base64," + testPicture(t), "seed": 3}
for _, streamed := range []bool{false, true} {
res, events := mcp(t, h, secret, "tools/call", map[string]any{"name": "generate_mesh", "arguments": args,
"_meta": map[string]any{"progressToken": "tok"}}, streamed)
info := resultInfo(t, res)
if info["status"] != "completed" || info["license"].(map[string]any)["name"] != "MIT" {
t.Fatalf("streamed %v: %v", streamed, info)
}
id, _ := info["file_id"].(string)
if !strings.HasSuffix(info["url"].(string), "/v1/files/"+id+"/content") {
t.Errorf("url %v", info["url"])
}
// Fetched with the same key, it is the mesh.
code, _, file := call(t, h, secret, http.MethodGet, "/v1/files/"+id+"/content", nil)
if code != http.StatusOK || !strings.HasPrefix(file, "glTF") {
t.Fatalf("the file is not the mesh: %d", code)
}
if streamed && !strings.Contains(strings.Join(events, "\n"), `"progressToken":"tok"`) {
t.Errorf("no progress said while it ran: %v", events)
}
}
// The job went through the client API as the caller: it is theirs.
_, list, _ := call(t, h, secret, http.MethodGet, meshesPath, nil)
if n := len(list["data"].([]any)); n != 2 {
t.Errorf("%d jobs of the caller's, want the two made", n)
}
// Under no key, nothing is made.
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
"params": map[string]any{"name": "generate_mesh", "arguments": args}})
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(b))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("a call with no key: %d", rec.Code)
}
}
func TestAPictureMadeThroughMCPComesWholeWhereNoFileCanBeKept(t *testing.T) {
rt, queued, mu := imageRouter(t)
h := rt.Handler()
res, _ := mcp(t, h, "", "tools/call", map[string]any{"name": "generate_image",
"arguments": map[string]any{"prompt": "a rock", "style": "concept", "size": 512, "seed": 9}}, false)
info := resultInfo(t, res)
if info["seed"] != 9.0 || info["model"] != "flux" || !strings.Contains(info["note"].(string), "keeps no files") {
t.Errorf("info %v", info)
}
content := res["content"].([]any)
if len(content) != 2 || content[1].(map[string]any)["type"] != "image" {
t.Fatalf("the picture is not in the answer: %v", content)
}
if b, _ := base64.StdEncoding.DecodeString(content[1].(map[string]any)["data"].(string)); string(b) != "png-bytes" {
t.Errorf("picture %q", b)
}
mu.Lock()
wf, _ := json.Marshal(*queued)
mu.Unlock()
if !strings.Contains(string(wf), "a rock, single object centered") || !strings.Contains(string(wf), `"width":512`) {
t.Errorf("the style was not added to the prompt: %s", wf)
}
}
func TestAPictureIsShrunkToItsLongSide(t *testing.T) {
var b bytes.Buffer
_ = png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 2000, 1000)))
out, err := shrink(b.Bytes(), 512)
if err != nil {
t.Fatal(err)
}
im, err := png.Decode(bytes.NewReader(out))
if err != nil || im.Bounds().Dx() != 512 || im.Bounds().Dy() != 256 {
t.Fatalf("%v %v", im.Bounds(), err)
}
b.Reset()
_ = png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 100, 50)))
if out, _ := shrink(b.Bytes(), 512); !bytes.Equal(out, b.Bytes()) {
t.Error("a small PNG was re-encoded")
}
}
func TestAWorkflowRunsThroughMCPFromItsDeclaredInputs(t *testing.T) {
wav := testWAV()
c := newRunComfy(t, wav, "song_00001_.wav")
rt, secret, _ := runRouter(t, c, songWorkflow, catalog.OutputAudio)
h := rt.Handler()
list, _ := mcp(t, h, secret, "tools/list", map[string]any{}, false)
if !strings.Contains(strings.Join(toolNames(list), ","), "run_workflow") {
t.Fatalf("no run_workflow for a model that declares its inputs: %v", toolNames(list))
}
res, _ := mcp(t, h, secret, "tools/call", map[string]any{"name": "run_workflow", "arguments": map[string]any{
"model": "ace", "inputs": map[string]any{"tags": "synthwave", "cover": "data:image/png;base64," + testPicture(t)}}}, false)
info := resultInfo(t, res)
if info["status"] != "completed" || info["content_type"] != "audio/wav" {
t.Fatalf("%v", info)
}
if _, _, file := call(t, h, secret, http.MethodGet, "/v1/files/"+info["file_id"].(string)+"/content", nil); file != string(wav) {
t.Error("the file is not the song")
}
// A run refused says why, as the API did.
res, _ = mcp(t, h, secret, "tools/call", map[string]any{"name": "run_workflow", "arguments": map[string]any{
"model": "ace", "inputs": map[string]any{"cover": "data:image/png;base64," + testPicture(t)}}}, false)
if res["isError"] != true || !strings.Contains(fmt.Sprint(res["content"]), "tags") {
t.Errorf("a run missing a required input: %v", res)
}
}
func TestPicturesAreLookedAtByAVisionModelShrunkAndToASchema(t *testing.T) {
e := newFakeEngine(t, "vlm")
cfg := baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: []string{"vlm"}})
vlm := catModel("vlm", nil)
vlm.Capabilities.Vision = true
cfg.Models = []catalog.Model{vlm}
h := newTestRouter(t, cfg).Handler()
var big bytes.Buffer
_ = png.Encode(&big, image.NewRGBA(image.Rect(0, 0, 3000, 1500)))
res, _ := mcp(t, h, "", "tools/call", map[string]any{"name": "review_images", "arguments": map[string]any{
"images": []string{base64.StdEncoding.EncodeToString(big.Bytes())},
"question": "Does the render match the concept?",
"json_schema": map[string]any{"type": "object", "properties": map[string]any{"match": map[string]any{"type": "boolean"}}},
}}, false)
if res["isError"] == true || !strings.Contains(fmt.Sprint(res["content"]), "hi") {
t.Fatalf("%v", res)
}
var sent struct {
ResponseFormat struct {
Type string `json:"type"`
} `json:"response_format"`
Messages []struct {
Content []struct {
Type string `json:"type"`
ImageURL struct {
URL string `json:"url"`
} `json:"image_url"`
} `json:"content"`
} `json:"messages"`
}
if err := json.Unmarshal(e.last.Load().([]byte), &sent); err != nil {
t.Fatal(err)
}
if sent.ResponseFormat.Type != "json_schema" || len(sent.Messages) != 1 || len(sent.Messages[0].Content) != 2 {
t.Fatalf("sent %+v", sent)
}
b64, _ := strings.CutPrefix(sent.Messages[0].Content[1].ImageURL.URL, "data:image/png;base64,")
pic, _ := base64.StdEncoding.DecodeString(b64)
im, err := png.Decode(bytes.NewReader(pic))
if err != nil || im.Bounds().Dx() != lookEdge || im.Bounds().Dy() != lookEdge/2 {
t.Errorf("the picture sent is %v (%v), not shrunk to %d", im.Bounds(), err, lookEdge)
}
}
// noisePNG is a picture that does not compress: n by n, so its base64 is
// past any small limit.
func noisePNG(t *testing.T, n int) []byte {
t.Helper()
im := image.NewRGBA(image.Rect(0, 0, n, n))
seed := uint32(1)
for i := range im.Pix {
seed = seed*1664525 + 1013904223
im.Pix[i] = byte(seed >> 24)
}
var b bytes.Buffer
_ = png.Encode(&b, im)
return b.Bytes()
}
func TestAPictureOfRealSizeGoesThroughMCPAndByFileID(t *testing.T) {
c := newMeshComfy(t)
rt, secret := meshRouter(t, c)
h := rt.Handler()
pic := noisePNG(t, 400) // ~640 KB, as a 1024 px concept is
res, _ := mcp(t, h, secret, "tools/call", map[string]any{"name": "generate_mesh", "arguments": map[string]any{
"model": "trellis2", "image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(pic)}}, false)
if info := resultInfo(t, res); info["status"] != "completed" {
t.Fatalf("a mesh from a picture of real size: %v", info)
}
// Uploaded once as a picture to work from, then named by id.
var form bytes.Buffer
mw := multipart.NewWriter(&form)
_ = mw.WriteField("purpose", "vision")
fw, _ := mw.CreateFormFile("file", "rock.png")
_, _ = fw.Write(pic)
_ = mw.Close()
req := httptest.NewRequest(http.MethodPost, "/v1/files", &form)
req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Authorization", "Bearer "+secret)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var f map[string]any
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &f) != nil || f["purpose"] != "vision" ||
f["expires_at"] == nil {
t.Fatalf("a picture uploaded to work from: %d %s", rec.Code, rec.Body.String())
}
res, _ = mcp(t, h, secret, "tools/call", map[string]any{"name": "generate_mesh", "arguments": map[string]any{
"model": "trellis2", "image": f["id"]}}, false)
if info := resultInfo(t, res); info["status"] != "completed" {
t.Fatalf("a mesh from an uploaded picture: %v", info)
}
}
func TestACallTooLargeIsSaidSo(t *testing.T) {
h := newTestRouter(t, baseConfig()).Handler()
big := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x","arguments":{"image":"` +
strings.Repeat("A", mcpBody) + `"}}}`
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(big))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusRequestEntityTooLarge || !strings.Contains(rec.Body.String(), "larger than") {
t.Errorf("%d %s", rec.Code, rec.Body.String()[:min(200, rec.Body.Len())])
}
}
func TestACallToAColdModelWaitsItOutSayingSo(t *testing.T) {
rt := newTestRouter(t, baseConfig())
_ = rt.Handler()
calls := 0
var fake http.Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
if calls < 3 {
w.Header().Set("Retry-After", "1")
w.Header().Set(waitingForHeader, "sf3d")
w.Header().Set(etaHeader, "40")
writeError(w, http.StatusServiceUnavailable, "server_error", "model loading: waiting")
return
}
w.Header().Set("x-llmgw-seed", "4")
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
})
rt.selfHandler.Store(&fake)
var said []string
c := mediaCall{req: httptest.NewRequest(http.MethodPost, "/mcp", nil), streamed: true,
progress: func(_ float64, m string) { said = append(said, m) }}
var out map[string]any
h, err := rt.selfJSONWaiting(c, "flux-schnell", imagesPath, map[string]any{}, &out)
if err != nil || out["ok"] != true || h.Get("x-llmgw-seed") != "4" || calls != 3 {
t.Fatalf("%v %v after %d calls", err, out, calls)
}
if len(said) != 2 || !strings.Contains(said[0], "waiting for sf3d to give up the GPU, about 40 s") {
t.Errorf("said %q", said)
}
// Refused for good — no Retry-After — is said at once.
calls = 0
fake = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
calls++
writeError(w, http.StatusPaymentRequired, "insufficient_quota", "the budget is spent")
})
rt.selfHandler.Store(&fake)
if _, err := rt.selfJSONWaiting(c, "flux-schnell", imagesPath, map[string]any{}, &out); err == nil ||
!strings.Contains(err.Error(), "budget") || calls != 1 {
t.Errorf("%v after %d calls", err, calls)
}
}
+61 -6
View File
@@ -3,8 +3,10 @@ package router
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"slices"
"strconv"
"time"
@@ -255,7 +257,17 @@ var mcpTools = []map[string]any{
// handleMCP serves POST /mcp.
func (r *Router) handleMCP(w http.ResponseWriter, req *http.Request) {
raw, err := io.ReadAll(http.MaxBytesReader(w, req.Body, maxMemoryBody))
// Pictures to make a mesh from or to look at come in the call, as
// base64.
raw, err := io.ReadAll(http.MaxBytesReader(w, req.Body, mcpBody))
if mbe := (*http.MaxBytesError)(nil); errors.As(err, &mbe) {
writeJSON(w, http.StatusRequestEntityTooLarge, rpcResponse{
JSONRPC: "2.0", ID: json.RawMessage("null"),
Error: &rpcError{Code: -32600, Message: fmt.Sprintf("the call is larger than %d MB: send pictures smaller, "+
"or fewer at once", mcpBody>>20)},
})
return
}
var rpc rpcRequest
if err != nil || json.Unmarshal(raw, &rpc) != nil || rpc.Method == "" {
writeJSON(w, http.StatusBadRequest, rpcResponse{
@@ -273,23 +285,66 @@ func (r *Router) handleMCP(w http.ResponseWriter, req *http.Request) {
resp := rpcResponse{JSONRPC: "2.0", ID: rpc.ID}
switch rpc.Method {
case "initialize":
resp.Result = map[string]any{
"protocolVersion": mcpProtocolVer,
result := map[string]any{
"protocolVersion": mcpVersionFor(rpc.Params),
"capabilities": map[string]any{"tools": map[string]any{}},
"serverInfo": map[string]any{"name": "llmgw-memory", "version": "1"},
"serverInfo": map[string]any{"name": "llmgw", "version": "1"},
}
if r.mediaModels().any() {
result["instructions"] = mcpMediaInstructions
}
resp.Result = result
case "ping":
resp.Result = map[string]any{}
case "tools/list":
resp.Result = map[string]any{"tools": mcpTools}
resp.Result = map[string]any{"tools": append(slices.Clone(mcpTools), r.mediaTools()...)}
case "tools/call":
resp.Result = r.callMemoryTool(req, rpc.Params)
var call struct {
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
Meta struct {
ProgressToken json.RawMessage `json:"progressToken"`
} `json:"_meta"`
}
_ = json.Unmarshal(rpc.Params, &call)
if !isMediaTool(call.Name) {
resp.Result = r.callMemoryTool(req, rpc.Params)
break
}
if acceptsStream(req) {
// Answered as a stream: how far along it is while it runs, which
// a mesh's minutes want, then the answer.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.WriteHeader(http.StatusOK)
sw := &streamWriter{w: w, token: call.Meta.ProgressToken}
stop := make(chan struct{})
go sw.keepAlive(stop, 15*time.Second)
resp.Result = r.callMediaTool(mediaCall{req: req, progress: sw.progress, streamed: true}, call.Name, call.Arguments)
close(stop)
sw.event(resp)
return
}
resp.Result = r.callMediaTool(mediaCall{req: req, progress: func(float64, string) {}}, call.Name, call.Arguments)
default:
resp.Error = &rpcError{Code: -32601, Message: "method not found: " + rpc.Method}
}
writeJSON(w, http.StatusOK, resp)
}
// mcpVersionFor is the protocol version answered: the client's, of those
// this server speaks, else its own.
func mcpVersionFor(params json.RawMessage) string {
var p struct {
ProtocolVersion string `json:"protocolVersion"`
}
_ = json.Unmarshal(params, &p)
if slices.Contains([]string{"2025-03-26", "2025-06-18"}, p.ProtocolVersion) {
return p.ProtocolVersion
}
return mcpProtocolVer
}
func toolText(text string, isError bool) map[string]any {
return map[string]any{
"content": []map[string]any{{"type": "text", "text": text}},
+2 -1
View File
@@ -101,7 +101,8 @@ func (r *Router) generateMeshes(ctx context.Context, w http.ResponseWriter, req
if err != nil {
return proxy.Outcome{}, err
}
tpl, err := comfy.Load(ep.Workflow)
wf, _ := ep.ComfyFor(req.Model)
tpl, err := comfy.Load(wf)
if err != nil {
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
}
+67 -8
View File
@@ -12,9 +12,10 @@ import (
// Anthropic's Messages API (/v1/messages), translated to a chat completion
// on the way in and back on the way out, like the Responses API: so Claude
// Code and Anthropic's SDKs reach the models here. Text, images, tools and
// their results, the system prompt and streaming are translated; extended
// thinking, prompt caching and server tools are not, and are ignored rather
// than refused.
// their results, the system prompt, streaming and extended thinking are
// translated; prompt caching and server tools are not, and are ignored rather
// than refused. Thinking asked for is a model's reasoning_content, given
// back as thinking blocks before the text; not asked for, it is left out.
type msgRequest struct {
Model string `json:"model"`
@@ -34,8 +35,16 @@ type msgRequest struct {
Metadata *struct {
UserID string `json:"user_id"`
} `json:"metadata"`
// Thinking is extended thinking: "enabled" (with a budget the engines
// here do not take) or "disabled".
Thinking *struct {
Type string `json:"type"`
} `json:"thinking"`
}
// thinks is whether the request asked for thinking blocks.
func (in msgRequest) thinks() bool { return in.Thinking != nil && in.Thinking.Type == "enabled" }
type msgMessage struct {
Role string `json:"role"`
Content json.RawMessage `json:"content"`
@@ -197,6 +206,16 @@ func messagesToChat(in msgRequest) (map[string]any, error) {
if in.Metadata != nil && in.Metadata.UserID != "" {
out["user"] = in.Metadata.UserID
}
// A model that thinks or not as its chat template is told (Qwen3, and
// the reasoning models served the same way) is told what was asked.
if in.Thinking != nil {
switch in.Thinking.Type {
case "enabled":
out["chat_template_kwargs"] = map[string]any{"enable_thinking": true}
case "disabled":
out["chat_template_kwargs"] = map[string]any{"enable_thinking": false}
}
}
var tools []map[string]any
for _, t := range in.Tools {
if t.Type != "" && t.Type != "custom" {
@@ -250,7 +269,7 @@ func (r *Router) handleMessages(w http.ResponseWriter, req *http.Request) {
next.ContentLength = int64(len(upstream))
next.Header.Set("Content-Type", "application/json")
t := &msgTranslator{w: w, model: in.Model, stream: in.Stream, id: newID("msg_")}
t := &msgTranslator{w: w, model: in.Model, stream: in.Stream, id: newID("msg_"), thinking: in.thinks()}
r.handleInference("/v1/chat/completions")(t, next)
t.finish()
}
@@ -309,6 +328,11 @@ type msgTranslator struct {
model string
stream bool
id string
// thinking is whether the reasoning is given as thinking blocks.
thinking bool
// thinkIndex and thinkOpen are the thinking block being streamed.
thinkIndex int
thinkOpen bool
status int
failed bool
@@ -415,6 +439,20 @@ func stopReason(finish string) string {
return "end_turn"
}
// thinkingSignature stands where Anthropic signs a thinking block: the
// thinking here comes from the gateway's own models, and nothing checks it,
// but the SDKs expect the field.
const thinkingSignature = "llmgw"
func (t *msgTranslator) closeThinking() {
if t.thinkOpen {
t.event("content_block_delta", map[string]any{"index": t.thinkIndex,
"delta": map[string]any{"type": "signature_delta", "signature": thinkingSignature}})
t.event("content_block_stop", map[string]any{"index": t.thinkIndex})
t.thinkOpen = false
}
}
func (t *msgTranslator) closeText() {
if t.textOpen {
t.event("content_block_stop", map[string]any{"index": t.textIndex})
@@ -433,8 +471,11 @@ func (t *msgTranslator) chatLine(line string) {
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
ToolCalls []struct {
Content string `json:"content"`
// The reasoning, by either name the engines give it.
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
Index int `json:"index"`
ID string `json:"id"`
Function struct {
@@ -461,7 +502,18 @@ func (t *msgTranslator) chatLine(line string) {
if ch.FinishReason != "" {
t.stopReason = stopReason(ch.FinishReason)
}
if r := ch.Delta.ReasoningContent + ch.Delta.Reasoning; r != "" && t.thinking {
if !t.thinkOpen {
t.thinkIndex, t.thinkOpen = t.nextBlock, true
t.nextBlock++
t.event("content_block_start", map[string]any{"index": t.thinkIndex,
"content_block": map[string]any{"type": "thinking", "thinking": ""}})
}
t.event("content_block_delta", map[string]any{"index": t.thinkIndex,
"delta": map[string]any{"type": "thinking_delta", "thinking": r}})
}
if d := ch.Delta.Content; d != "" {
t.closeThinking()
if !t.textOpen && t.text.Len() == 0 {
t.textIndex, t.textOpen = t.nextBlock, true
t.nextBlock++
@@ -477,6 +529,7 @@ func (t *msgTranslator) chatLine(line string) {
for _, tc := range ch.Delta.ToolCalls {
c := t.byIndex[tc.Index]
if c == nil {
t.closeThinking()
t.closeText()
for _, o := range t.calls {
if o.open {
@@ -532,6 +585,7 @@ func (t *msgTranslator) finish() {
t.chatLine(rest)
}
t.begin()
t.closeThinking()
t.closeText()
for _, c := range t.calls {
if c.open {
@@ -553,8 +607,10 @@ func (t *msgTranslator) fromChatJSON(b []byte) {
var c struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []struct {
Content string `json:"content"`
ReasoningContent string `json:"reasoning_content"`
Reasoning string `json:"reasoning"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
@@ -578,6 +634,9 @@ func (t *msgTranslator) fromChatJSON(b []byte) {
if len(c.Choices) > 0 {
ch := c.Choices[0]
reason = stopReason(ch.FinishReason)
if r := ch.Message.ReasoningContent + ch.Message.Reasoning; r != "" && t.thinking {
content = append(content, map[string]any{"type": "thinking", "thinking": r, "signature": thinkingSignature})
}
if ch.Message.Content != "" {
content = append(content, map[string]any{"type": "text", "text": ch.Message.Content})
}
+62
View File
@@ -139,3 +139,65 @@ func TestAnAnthropicClientsKeyIsAccepted(t *testing.T) {
t.Errorf("%d %s", rec.Code, rec.Body.String())
}
}
func TestThinkingAskedForComesBackAsThinkingBlocks(t *testing.T) {
// A reasoning model's thoughts were dropped: Claude Code asking for
// extended thinking got the answer alone.
var in msgRequest
_ = json.Unmarshal([]byte(`{"model":"m","max_tokens":10,"thinking":{"type":"enabled","budget_tokens":2048},
"messages":[{"role":"user","content":"hi"}]}`), &in)
chat, _ := messagesToChat(in)
if b, _ := json.Marshal(chat); !strings.Contains(string(b), `"chat_template_kwargs":{"enable_thinking":true}`) {
t.Errorf("the model was not told to think: %s", b)
}
whole := `{"choices":[{"message":{"reasoning_content":"two plus two","content":"4"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":3,"completion_tokens":5}}`
_, h := messagesRouter(t, whole)
body := map[string]any{"model": "m", "max_tokens": 10, "thinking": map[string]any{"type": "enabled", "budget_tokens": 1024},
"messages": []any{map[string]any{"role": "user", "content": "2+2?"}}}
var out struct {
Content []map[string]any `json:"content"`
}
_ = json.Unmarshal(sendMessages(t, h, body).Body.Bytes(), &out)
if len(out.Content) != 2 || out.Content[0]["type"] != "thinking" || out.Content[0]["thinking"] != "two plus two" ||
out.Content[0]["signature"] == nil || out.Content[1]["text"] != "4" {
t.Errorf("content %v", out.Content)
}
// Not asked for, it is left out, as before.
delete(body, "thinking")
_ = json.Unmarshal(sendMessages(t, h, body).Body.Bytes(), &out)
if len(out.Content) != 1 || out.Content[0]["type"] != "text" {
t.Errorf("unasked, content %v", out.Content)
}
}
func TestStreamedThinkingIsItsOwnBlock(t *testing.T) {
stream := "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"two \"}}]}\n\n" +
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"plus two\"}}]}\n\n" +
"data: {\"choices\":[{\"delta\":{\"content\":\"4\"},\"finish_reason\":\"stop\"}]}\n\n" +
"data: [DONE]\n\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
return
}
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte(stream))
}))
t.Cleanup(srv.Close)
rt := newTestRouter(t, baseConfig(config.Endpoint{ID: "a", URL: srv.URL, Models: []string{"m"}}))
rec := sendMessages(t, rt.Handler(), map[string]any{"model": "m", "max_tokens": 10, "stream": true,
"thinking": map[string]any{"type": "enabled", "budget_tokens": 1024},
"messages": []any{map[string]any{"role": "user", "content": "2+2?"}}})
got := rec.Body.String()
for _, s := range []string{`"content_block":{"thinking":"","type":"thinking"},"index":0`,
`"delta":{"thinking":"two ","type":"thinking_delta"}`, `"type":"signature_delta"`,
`"content_block":{"text":"","type":"text"},"index":1`, `"text":"4","type":"text_delta"`} {
if !strings.Contains(got, s) {
t.Errorf("lacks %s in\n%s", s, got)
}
}
if strings.Index(got, "signature_delta") > strings.Index(got, `"text_delta"`) {
t.Error("the thinking block was not closed before the text began")
}
}
+18 -2
View File
@@ -51,6 +51,10 @@ type Router struct {
cfg config.Router
log *slog.Logger
// selfHandler is the client API as Handler built it, key check and all:
// what the MCP tools make their requests through, as the caller.
selfHandler atomic.Pointer[http.Handler]
reg *endpoint.Registry
poller *scrape.Poller
px *proxy.Proxy
@@ -1018,6 +1022,13 @@ func (r *Router) dispatchOne(ctx context.Context, w http.ResponseWriter, req *pa
}
candidates = visited
}
if len(req.avoid) > 0 {
if rest := slices.DeleteFunc(slices.Clone(candidates), func(e endpoint.Snapshot) bool {
return slices.Contains(req.avoid, e.ID)
}); len(rest) > 0 {
candidates = rest
}
}
if len(candidates) == 0 {
// Nothing is serving it. That may be because nothing ever will, or
// because it loads when asked and nobody has asked yet — and the second
@@ -1102,6 +1113,9 @@ func (r *Router) dispatchOne(ctx context.Context, w http.ResponseWriter, req *pa
candidates = without(candidates, dec.Endpoint.ID)
continue
}
if req.picked != nil {
req.picked(dec.Endpoint.ID)
}
// The replica loads the adapter first if it does not hold it.
// Counted against its inflight while it does, so the requests
@@ -1134,8 +1148,10 @@ func (r *Router) dispatchOne(ctx context.Context, w http.ResponseWriter, req *pa
trace.WithAttributes(attribute.String("llmgw.endpoint", dec.Endpoint.ID),
attribute.String("url.full", dec.Endpoint.URL+req.Path)))
body := req.UpstreamBody
if dec.Endpoint.UpstreamModel != "" && req.ContentType == "" {
body = withModel(body, dec.Endpoint.UpstreamModel)
// A shared ComfyUI replica is told which of its models' checkpoint;
// any other endpoint its own upstream name.
if _, up := dec.Endpoint.ComfyFor(model); up != "" && req.ContentType == "" {
body = withModel(body, up)
}
// llama-server is asked for the model with the adapter applied to
// this request; vLLM serves each adapter as a model of its own name,
+6
View File
@@ -42,6 +42,8 @@ type fakeEngine struct {
last atomic.Value
// traceparent is the one the last completion came with.
traceparent atomic.Value
// hang, when set, has a completion wait until its request is given up.
hang atomic.Bool
}
func newFakeEngine(t *testing.T, model string) *fakeEngine {
@@ -63,6 +65,10 @@ func newFakeEngine(t *testing.T, model string) *fakeEngine {
http.Error(w, "unavailable", code)
return
}
if f.hang.Load() {
<-r.Context().Done()
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprintf(w, `{"model":%q,"choices":[{"message":{"content":"hi"}}],"usage":{"prompt_tokens":4,"completion_tokens":2}}`, model)
})
+2 -1
View File
@@ -297,7 +297,8 @@ func (r *Router) generateRun(ctx context.Context, w http.ResponseWriter, req *pa
if err != nil {
return proxy.Outcome{}, err
}
tpl, err := comfy.Load(ep.Workflow)
wf, _ := ep.ComfyFor(req.Model)
tpl, err := comfy.Load(wf)
if err != nil {
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
}
+17 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"strings"
"time"
)
@@ -193,13 +194,26 @@ func (s *DB) AllJobs(ctx context.Context, status string, limit int) ([]Job, erro
// ClaimJob takes the lease on the oldest job with work left that no gateway
// holds, for owner until the time given. ok is false when there is none.
func (s *DB) ClaimJob(ctx context.Context, owner string, now, until time.Time) (Job, bool, error) {
//
// Jobs for the models in skip are passed over: a worker running as many of a
// model's jobs as it will at once takes the next job for another model
// rather than waiting behind it.
func (s *DB) ClaimJob(ctx context.Context, owner string, now, until time.Time, skip ...string) (Job, bool, error) {
var id string
args := []any{owner, millis(until), JobQueued, JobRunning, JobCancelling, millis(now)}
notIn := ""
if len(skip) > 0 {
notIn = " AND model NOT IN (?" + strings.Repeat(", ?", len(skip)-1) + ")"
for _, m := range skip {
args = append(args, m)
}
}
args = append(args, millis(now))
err := s.db.QueryRowContext(ctx, `UPDATE jobs SET lease_owner = ?, lease_until = ?
WHERE id = (SELECT id FROM jobs WHERE status IN (?, ?, ?) AND lease_until < ?
WHERE id = (SELECT id FROM jobs WHERE status IN (?, ?, ?) AND lease_until < ?`+notIn+`
ORDER BY created_at LIMIT 1)
AND lease_until < ?
RETURNING id`, owner, millis(until), JobQueued, JobRunning, JobCancelling, millis(now), millis(now)).Scan(&id)
RETURNING id`, args...).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return Job{}, false, nil
}
+21
View File
@@ -120,3 +120,24 @@ func TestAnOperatorSeesEveryOwnersJobs(t *testing.T) {
t.Errorf("%d queued", len(queued))
}
}
func TestAClaimPassesOverTheModelsItIsToldTo(t *testing.T) {
db := openDB(t)
ctx := t.Context()
now := time.Now()
for i, m := range []string{"sf3d", "flux"} {
at := now.Add(time.Duration(i) * time.Second)
if err := db.PutJob(ctx, Job{ID: "job_" + m, Owner: "u", KeyID: "k", Endpoint: "/e", Model: m, Input: []byte("{}"),
Status: JobQueued, CreatedAt: at, ExpiresAt: at.Add(time.Hour)}); err != nil {
t.Fatal(err)
}
}
j, ok, err := db.ClaimJob(ctx, "w", now, now.Add(time.Minute), "sf3d")
if err != nil || !ok || j.Model != "flux" {
t.Errorf("claimed %+v %v %v; sf3d was to be passed over", j.ID, ok, err)
}
j, ok, _ = db.ClaimJob(ctx, "w", now, now.Add(time.Minute))
if !ok || j.Model != "sf3d" {
t.Errorf("then %+v %v", j.ID, ok)
}
}
+34 -3
View File
@@ -2,17 +2,46 @@ package store
import (
"context"
"database/sql"
"fmt"
"net"
"time"
// The pure-Go PostgreSQL driver. Pure-Go is not a preference here: the
// whole binary is built with CGO_ENABLED=0 so that it runs on a node whose
// libc is older than the builder's, and a cgo driver would take that away
// from every deployment, including the ones still on SQLite.
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/stdlib"
)
// How soon a store machine that is gone is given up on. A replicated store's
// address names every member, and a new connection tries them in order: with
// no timeout, one to a machine that had lost its power waited for the
// kernel's own — half a minute and more — before the next member was asked,
// and every request whose key was not cached waited with it.
const (
// storeConnectTimeout is per member: the next is tried after it.
storeConnectTimeout = 3 * time.Second
// storeDeadAfter is how long a connection's sent data may go unanswered,
// or an idle one's probes, before it is closed: a connection to the
// primary that was lost fails, and the next opens to the new one.
storeDeadAfter = 10 * time.Second
)
// failFast gives a connection configuration those limits, where its address
// gave none of its own.
func failFast(cfg *pgx.ConnConfig) {
if cfg.ConnectTimeout == 0 {
cfg.ConnectTimeout = storeConnectTimeout
}
d := &net.Dialer{
KeepAliveConfig: net.KeepAliveConfig{Enable: true, Idle: 5 * time.Second, Interval: time.Second,
Count: int((storeDeadAfter - 5*time.Second) / time.Second)},
Control: userTimeout(storeDeadAfter),
}
cfg.DialFunc = d.DialContext
}
// OpenPostgres opens the store against PostgreSQL.
//
// The same queries run here as on SQLite; only the placeholders differ, and
@@ -29,12 +58,14 @@ func OpenPostgres(ctx context.Context, dsn string) (*DB, error) {
if dsn == "" {
return nil, fmt.Errorf("store: no PostgreSQL address configured")
}
db, err := sql.Open("pgx", dsn)
cfg, err := pgx.ParseConfig(dsn)
if err != nil {
// Deliberately not wrapped with the DSN: it holds a password, and an
// error string ends up in logs, tickets and screenshots.
return nil, fmt.Errorf("opening the PostgreSQL store: %w", err)
}
failFast(cfg)
db := stdlib.OpenDB(*cfg)
// Sized for a control plane rather than for a fleet of application
// servers. The request path barely touches the store — usage is batched,
+25
View File
@@ -0,0 +1,25 @@
package store
import (
"testing"
"time"
"github.com/jackc/pgx/v5"
)
func TestAStoreMachineThatIsGoneIsGivenUpOnInSeconds(t *testing.T) {
cfg, err := pgx.ParseConfig("postgres://u:p@10.0.0.158:5432,10.0.0.159:5432/llmgw?sslmode=disable&target_session_attrs=read-write")
if err != nil {
t.Fatal(err)
}
failFast(cfg)
if cfg.ConnectTimeout != storeConnectTimeout || cfg.DialFunc == nil {
t.Errorf("timeout %s, dialer set %v", cfg.ConnectTimeout, cfg.DialFunc != nil)
}
// One the address asks for is its own.
cfg, _ = pgx.ParseConfig("postgres://u:p@h:5432/llmgw?sslmode=disable&connect_timeout=12")
failFast(cfg)
if cfg.ConnectTimeout != 12*time.Second {
t.Errorf("the address's own timeout was replaced: %s", cfg.ConnectTimeout)
}
}
+23
View File
@@ -0,0 +1,23 @@
package store
import (
"syscall"
"time"
"golang.org/x/sys/unix"
)
// userTimeout sets TCP_USER_TIMEOUT: data sent and not acknowledged for that
// long closes the connection, where keepalive probes wait for it to go idle.
func userTimeout(d time.Duration) func(network, address string, c syscall.RawConn) error {
return func(_, _ string, c syscall.RawConn) error {
var serr error
err := c.Control(func(fd uintptr) {
serr = unix.SetsockoptInt(int(fd), unix.IPPROTO_TCP, unix.TCP_USER_TIMEOUT, int(d.Milliseconds()))
})
if err != nil {
return err
}
return serr
}
}
+13
View File
@@ -0,0 +1,13 @@
//go:build !linux
package store
import (
"syscall"
"time"
)
// userTimeout is Linux's alone; elsewhere the keepalive probes do it, later.
func userTimeout(time.Duration) func(network, address string, c syscall.RawConn) error {
return nil
}
+66 -1
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"slices"
@@ -342,7 +343,14 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) {
// between the listing and the click, or a mirror that has gone
// down since, both end as a replica failing on a node minutes
// later with an error naming the node. Asking costs one HEAD.
if _, err := sourcecheck.Check(ctx, model.Source); err != nil {
size, err := sourcecheck.Check(ctx, model.Source)
if err != nil {
return "", err
}
// The weights and the context's cache have to fit the card:
// an 8B asked for a 131072-token pool on 24 GB loaded its
// weights and failed to make its context, again and again.
if err := p.fitsCard(ctx, l, model, size); err != nil {
return "", err
}
if err := d.Catalog.Add(ctx, model, admin.Email); err != nil {
@@ -365,6 +373,14 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) {
mux.HandleFunc("POST /admin/mirror/{model}/{version}/remove", p.mirrorAction(m,
func(ctx context.Context, _ store.User, r *http.Request) (string, error) {
model, ver := r.PathValue("model"), r.PathValue("version")
// Weights a catalog model still loads are not taken from under
// it: removed from the mirror alone, the model stayed in the
// catalog and its replicas went on loading the copy on the node.
if users := p.mirrorUsers(ctx, model, ver); len(users) > 0 {
return "", fmt.Errorf("%s/%s is the weights of %s in the catalog: remove %s first "+
"(the models page, or llmgw ctl model remove %s), then the weights",
model, ver, strings.Join(users, ", "), strings.Join(users, ", "), users[0])
}
removed, err := m.Remove(ctx, model, ver)
if err != nil {
return "", err
@@ -937,3 +953,52 @@ func sharedPrefix(ids []string) string {
}
return ""
}
// mirrorUsers are the catalog models whose weights are the mirror's
// model/version.
func (p *AccountPages) mirrorUsers(ctx context.Context, model, ver string) []string {
if p.catalog == nil {
return nil
}
srcs, err := p.catalog.Sources(ctx)
if err != nil {
return nil
}
part := "/" + model + "/" + ver + "/"
var out []string
for src, ms := range srcs {
if !strings.Contains(src, part) {
continue
}
for _, m := range ms {
if !slices.Contains(out, m.Name) {
out = append(out, m.Name)
}
}
}
slices.Sort(out)
return out
}
// fitsCard refuses a model whose weights and context pool do not fit the
// cards its profile names, saying the largest pool that would.
func (p *AccountPages) fitsCard(ctx context.Context, l *Lang, m catalog.Model, size int64) error {
if len(m.Profiles) == 0 || m.Profiles[0].GPUClass == "" || size <= 0 {
return nil
}
pr := m.Profiles[0]
v := p.checkSource(ctx, m.Source)
cards := max(pr.GPUsPerNode, pr.TP, 1)
most := v.Header.PoolThatFits(size, gguf.ClassMemGB(pr.GPUClass), cards)
pool := argInt(pr.Args, "--ctx-size")
if pool <= 0 {
pool = defaultCtxPool
}
switch {
case most < 0:
return errors.New(l.T("serve.err.weights_too_big", l.Num(float64(size)/(1<<30), 1), pr.GPUClass))
case most > 0 && pool > most:
return errors.New(l.T("serve.err.pool_too_big", l.Int(int64(pool)), pr.GPUClass, l.Int(int64(most/1024*1024))))
}
return nil
}
+29 -4
View File
@@ -239,14 +239,14 @@ func TestThePageReloadsOnlyWhileSomethingIsDownloading(t *testing.T) {
// A reload while nothing is happening would take with it whatever the
// operator was typing into the fetch form.
if strings.Contains(idle, "http-equiv=\"refresh\"") {
if strings.Contains(idle, "name=\"llmgw-refresh\"") {
t.Error("an idle page should not reload itself")
}
busy := mirrorGet(t, mirrorPage(t, &fakeMirror{models: []mirror.Status{
{Model: "m", Version: "v", File: "f", State: mirror.Fetching},
}}, true), "/admin/mirror").Body.String()
if !strings.Contains(busy, "http-equiv=\"refresh\"") {
if !strings.Contains(busy, "name=\"llmgw-refresh\"") {
t.Error("a page with a download running should follow it")
}
}
@@ -680,7 +680,7 @@ func TestEachTabDoesOnlyItsOwnWork(t *testing.T) {
if strings.Contains(held, `name="source" required`) || strings.Contains(held, "Search Hugging Face") {
t.Error("other tabs' forms are on the list")
}
if !strings.Contains(held, `http-equiv="refresh"`) {
if !strings.Contains(held, `name="llmgw-refresh"`) {
t.Error("the list stopped following a download")
}
@@ -688,7 +688,7 @@ func TestEachTabDoesOnlyItsOwnWork(t *testing.T) {
if reg.explored != 1 || !strings.Contains(explore, "Search Hugging Face") {
t.Error("the explorer tab did not explore")
}
if strings.Contains(explore, `id="held"`) || strings.Contains(explore, `http-equiv="refresh"`) {
if strings.Contains(explore, `id="held"`) || strings.Contains(explore, `name="llmgw-refresh"`) {
t.Error("the explorer shows the list, or reloads away a search")
}
// A link from before there were tabs still lands on the explorer.
@@ -766,3 +766,28 @@ func TestAJudgesWeightsAreServedAsTheJudgeThePoliciesAsk(t *testing.T) {
t.Errorf("a model clients use became a judge: %+v", plain)
}
}
func TestWeightsAModelStillLoadsAreNotRemoved(t *testing.T) {
// Removed from the mirror alone, a model stayed in the catalog and its
// replicas went on loading the copy on the node, with nothing said.
m := &fakeMirror{}
cat := &fakeCatalog{sources: map[string][]catalog.Model{
"http://10.0.0.146:8081/apertus-70b/aff98136/Apertus-70B-UD-IQ1_M.gguf": {{Name: "apertus-70b-instruct-2509"}},
}}
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
mux := http.NewServeMux()
p.RegisterAdminMirror(mux, MirrorDeps{Mirror: m, Catalog: cat})
body := mirrorPost(t, mux, "/admin/mirror/apertus-70b/aff98136/remove", url.Values{"csrf": {"tok"}}).Body.String()
if m.removed != [2]string{} {
t.Errorf("weights in use were removed: %v", m.removed)
}
if !strings.Contains(body, "apertus-70b-instruct-2509") || !strings.Contains(body, "remove") {
t.Errorf("the page does not say which model uses them: %s", body)
}
// Weights nothing loads go as before.
mirrorPost(t, mux, "/admin/mirror/other/v1/remove", url.Values{"csrf": {"tok"}})
if m.removed != [2]string{"other", "v1"} {
t.Errorf("unused weights were not removed: %v", m.removed)
}
}
Loaded 100 of 110 files, more files were not shown because too many files have changed in this diff. Show more