Compare 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
Ilker ManapandClaude Opus 5.5 45666e72db v0.14.5: changelog and README
Release / build (push) Successful in 5m42s
CI / test (push) Successful in 11m27s
Release / notes (push) Successful in 4s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 01:33:59 +02:00
Ilker ManapandClaude Opus 5.5 2eb2a6d8fe A mesh carries its whole licence, not only its name
CI / test (push) Failing after 6m25s
GLBs carried the licence's name, URL and notice in asset.extras.license.
A pipeline that sorts assets by licence needs what a machine matches on:
the id, whether commercial use is allowed, the regions it excludes, the
revenue cap and the attribution. The whole licence is written, as
/v1/models gives it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 01:12:47 +02:00
Ilker ManapandClaude Opus 5.5 cfc636e328 A mesh's base colour can be brought to the picture it was made from
CI / test (push) Failing after 6m28s
Stable Fast 3D takes the light out of the picture it sees, and a dark
basalt (median 0.17) came back a light stone dimly lit (0.67). The save
node takes albedo_match, the picture and its mask, and scales the base
colour texture in linear light by the ratio of the object's medians, so
marks keep their contrast; texels nothing maps to are left alone. On the
game team's rock it is 0.18.

A workflow's {{name|false}} default now fills a BOOLEAN input with a
boolean rather than the word.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:51:06 +02:00
Ilker ManapandClaude Opus 5.5 848db08469 The cluster page and the console say which picture and 3D models are here, and add them
CI / test (push) Failing after 6m27s
A site whose catalog had no flux-schnell or triposr showed no Images or 3D
page, and nothing said they were one click away. The cluster page now has
a Pictures and 3D models card under the models: each preset, what it
makes, the classes of card here it fits, whether and by whom it was
added, and why the leader has not added it by itself; an Add button, which
says first when it would install ComfyUI on the GPU nodes and restart the
control planes; and the switch for presets.auto. The console's models
screen shows the same under its table: A adds one, asking first in the
same case, and P turns automatic adding on or off.

`ctl model add PRESET` and `ctl model presets` go through the presets API,
so the control plane keeps who added one, and `ctl model presets auto
on|off` flips the setting; against a control plane from before, add sends
the entry as v0.14.4 did.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 20:48:40 +02:00
Ilker ManapandClaude Opus 5.5 f42af27da5 The leader adds the picture and 3D presets a node fits, unless told not to
A customer's portal had no Images or 3D pages: nobody had written a
definition for either, on a cluster whose V100 runs both. The leading
control plane now adds flux-schnell and triposr, on demand, once a node
has a card that fits one, the cluster installs ComfyUI, and the catalog
makes nothing of that kind. presets.auto (a live setting, on by default)
turns it off; a preset somebody removes is recorded as declined and not
added back until added again by hand. Each addition is in the audit log.

GET /api/v1/presets says how each preset stands, the ComfyUI the cluster
installs, and why one is not added; POST /api/v1/presets/{name} adds one
(setting engines.comfyui where there is none); PUT /api/v1/presets/auto
switches it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 20:48:40 +02:00
Ilker ManapandClaude Opus 5.5 d817558121 v0.14.4: changelog and README
Release / build (push) Successful in 2m4s
Release / notes (push) Successful in 4s
CI / test (push) Successful in 9m28s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 10:37:05 +02:00
Ilker ManapandClaude Opus 5.5 295186a318 CHANGELOG: presets, ComfyUI on a V100, the open driver's version
CI / test (push) Successful in 10m11s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:21:51 +02:00
Ilker ManapandClaude Opus 5.5 40b8948d7d A picture or 3D model is added by its name: llmgw ctl model add triposr
flux-schnell and triposr come with llmgw as presets: their workflow,
custom nodes, packages and licence as they were found and tried on the
PoC, and a profile that says the card it needs rather than a class, so the
entry places on any site's cards. TripoSR's workflow is built in, with the
LLMGWPrepareForMesh node that keeps a picture's background out of the
mesh, which the copy in the document had left out.

`ctl model add PRESET` adds one, and has the nodes install ComfyUI when
the cluster's settings name none; `ctl model presets` lists them; the
console's add-model dialog offers them beside a definition file. A
ComfyUI build is now also checked to keep its order on a Turing card, so
a node's installed cu13 is not replaced by the cu126 build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:21:28 +02:00
Ilker ManapandClaude Opus 5.5 52898fbb80 A V100 gets a ComfyUI it can run, and an open driver is read by its version
PyTorch's CUDA 12.8 and 12.9 builds start at Turing since torch 2.8, as
CUDA 13 does, so on a V100 both ComfyUI builds were installed, 10 GB each,
and failed on the card with "no kernel image". The cu129 build now needs
7.5, and a third, on PyTorch's CUDA 12.6 torch, carries Volta and Pascal.

The open kernel module, NVIDIA's default since 560, writes "Open Kernel
Module for x86_64  580.82.07" in /proc/driver/nvidia/version, and the word
after "Module" was taken as the driver: "for". With the driver unknown,
every build was tried whatever driver it needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:21:27 +02:00
Ilker ManapandClaude Opus 5.5 7781b6a308 CHANGELOG: the gated repository a node loads by name
CI / test (push) Successful in 12m24s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 10:20:07 +02:00
Ilker ManapandClaude Opus 5.5 fa6e68c8e8 A model put first is not evicted, idle, for a lower class
CI / test (push) Failing after 7m59s
The VLM is to come first on the one card. Its class makes it take the card
from flux, triposr or sf3d when asked for, but idle eviction ignored class:
a 3D job two minutes into a lull would have taken the card from it. Idle
eviction offers only replicas of no higher class than the model wanting
room, and the wait such a model is told runs to the holder's idle timeout.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 10:15:14 +02:00
Ilker ManapandClaude Opus 5.5 967df77e53 The agent hands the gate the repository a node loads its weights by
CI / test (push) Failing after 6m22s
sf3d's first jobs failed with a 401 from stabilityai/stable-fast-3d. The
weights were on the node, and the gate links them into the Hugging Face
cache when told the repository — but the agent passed on only the custom
nodes, so nothing was linked and the node went to the network for a gated
repository.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 23:28:22 +02:00
Ilker ManapandClaude Opus 5.5 3c626cdc96 A model removed from the catalog is no longer wanted
CI / test (push) Successful in 10m18s
Removed and added again to give it a licence, qwen3-vl-8b came back loading
by itself: its name was still on the wanted list from before. A model that
leaves the catalog leaves the list too.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 20:50:57 +02:00
Ilker ManapandClaude Opus 5.5 3858114ada A picture in a prompt counts as what a model makes of it, not its base64
CI / test (push) Successful in 10m43s
A 1280x720 PNG sent to qwen3-vl-8b was estimated at 138 thousand prompt
tokens and refused against its 8k context before reaching the engine, which
makes about a thousand of it. Parts are counted by kind: text by its text,
a picture or a sound as a thousand tokens.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 20:45:06 +02:00
Ilker ManapandClaude Opus 5.5 ac4360b3fd A custom node's compiled packages are built for the card that runs them
CI / test (push) Failing after 6m23s
Stable Fast 3D's texture_baker and uv_unwrapper are C++/CUDA packages no
index has; on the PoC they were built and installed into ComfyUI's
environment by hand. nodes[].build names such directories, and the gate
builds each as a wheel with ComfyUI's Python, for the compute capability
torch reports for the replica's own card, keeps it on the node by the
node's commit, the architecture and the directory, and installs it
beside the replica with the node's requirements. A copy in ComfyUI's
environment does not pin it. A node with no CUDA toolkit says so.

comfyui.huggingface_repo shows the replica's weights to a node that
loads them by repository name, in the engines' Hugging Face cache, so a
gated repository needs no token on the node.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 17:23:05 +02:00
Ilker ManapandClaude Opus 5.5 587d8d5059 A licence left out by its name stays out once its entry gives an id
CI / test (push) Failing after 6m30s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 16:28:40 +02:00
Ilker ManapandClaude Opus 5.5 4bbac0833d A person leaves licences out, and no key of theirs reaches a model under one
CI / test (push) Successful in 11m28s
A game sold in the EU cannot ship what a licence excluding the EU made. On
the profile page a person leaves out licences one by one, or by rule:
every licence forbidding commercial use, every licence granting nothing in
a region. The rules cover models added later. The choice reaches every
principal through the resolved limits, so permits() leaves such models out
of /v1/models, requests, jobs, fallbacks and the portal's pickers, and a
request for one is refused 403 licence_excluded saying which choice did.

Kept in user_excluded_licences (migration 0046), copied with the store and
gone with the person.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 16:10:22 +02:00
Ilker ManapandClaude Opus 5.5 a313b3e0da The reference documents a profile's gpu need and its own comfyui block
CI / test (push) Failing after 8m7s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 16:10:16 +02:00
Ilker ManapandClaude Opus 5.5 777a9223a7 A profile may name the card it needs rather than its class, and fit any site
CI / test (push) Failing after 6m19s
A catalog entry meant for many sites cannot know their GPU class names:
the PoC has a Quadro RTX 6000, a customer an A100, another an L40S. A
profile may now say gpu: {min_compute_capability, min_memory_gb} in
place of gpu_class, and stands for every class in the cluster that meets
it, named <profile>@<class>, at its own priority; placement still sees
exact classes. ProfileByName and EngineProfile resolve such a name to
the profile on that class, so every caller that maps a replica back to
its profile works unchanged. A ComfyUI profile may carry its own
workflow, checkpoint and nodes, and with its own source an fp8 workflow
and weights go to cards with fp8 and fp16 ones to Turing.

A profile by need is left out of placement until the cluster's cards are
known: with no class it would have read as a CPU profile.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 16:02:32 +02:00
Ilker ManapandClaude Opus 5.5 b6bfc33ef7 Any ComfyUI workflow runs as a job, from the inputs its catalog entry declares
CI / test (push) Successful in 9m7s
A model's catalog entry now says what its workflow takes and makes:
comfyui.inputs, each a placeholder of its name with a type (text, int,
float, bool, choice, image, mask, audio, video, file), bounds, choices
and a default; and comfyui.output, which gains video, audio and files
beside pictures and meshes. POST /v1/workflows/runs with {model, inputs}
checks a request against them before a job is written — a required input
left out, an unknown one, a number out of bounds, a song sent as a
picture, another owner's file id are each refused with what was wrong —
and the job worker replays it to a replica, where the files go into
ComfyUI's input directory, the workflow is filled and run, and the first
file of the model's output kind is the job's result. /v1/models lists
each ComfyUI model's workflow, so a client can build its form.

A PNG result carries the licence in an iTXt chunk, as a GLB does in
asset.extras. A file whose server named no type is typed by its name
before its bytes, so a WAV is audio/wav, not audio/wave.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 14:28:15 +02:00
Ilker ManapandClaude Opus 5.5 b43e66c95e ComfyUI models that agree on their nodes share an environment; a licence says what a machine can match
CI / test (push) Failing after 6m21s
A ComfyUI model may name an environment: the models of one environment
agree on every custom node they both name and on every package range,
so one replica can carry all of them and ComfyUI moves between their
weights itself rather than the card being handed over with a cold start
each time. The catalog refuses an environment that cannot share a
Python, naming both models; Catalog.Environments gives each one's
models, the union of their nodes and their weight sources, for
placement to build on.

A licence gains an id (SPDX where there is one), whether its output may
be used commercially, the regions it excludes, a revenue cap and the
attribution it asks for, so a person's or a site's choice of licences
can be matched without reading prose.

BACKLOG gains section H: ComfyUI as a platform.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 09:19:50 +02:00
Ilker ManapandClaude Opus 5.5 0639edfd3b An adopted on-demand replica counts as used when adopted
CI / test (push) Failing after 6m25s
After a control plane restart flux, adopted and asked for by nobody, had no
last use and so was never idle enough to evict: a 3D job waited out its
whole fifteen minutes with no estimate. It counts as used at adoption, as it
is already counted as wanted then; a replica in use is reported by the
router within seconds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 08:55:33 +02:00
Ilker ManapandClaude Opus 5.5 95c61daf1b Jobs are served by the leader, and a moment of fitting nowhere is waited out
CI / test (push) Failing after 8m47s
A 3D job leased by the standby control plane, whose router has no endpoints
and which a job replayed in the process never leaves, was told "loading"
for seven minutes while the leader served the jobs behind it. Jobs and
batches are claimed only while leading.

Five jobs asked for while cuda01's agent restarted failed for good as
"cannot be placed": re-registering, the node had no engines for a few
seconds. A job asks again for five minutes before that is its end.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 23:53:33 +02:00
Ilker ManapandClaude Opus 5.5 9b8ec89247 A mesh node's result is a file the gate can fetch
CI / test (push) Successful in 8m59s
Stable Fast 3D's own save node hands back the GLB as base64 in its UI
output, which the gate does not read. LLMGWSaveMesh writes each mesh to
output/ as a GLB and names it, as a picture's save node does. The
prepare node also gives the object's mask now, which SF3D's sampler
takes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 22:29:34 +02:00
Ilker ManapandClaude Opus 5.5 b1490dbf30 A job waiting for the card says what holds it and about how long
CI / test (push) Failing after 6m16s
A 3D job waiting for the picture model to give up the one card had an empty
progress; its client could not tell waiting from stuck. The control plane
says which model holds the cards and when it lets go, plus the waiting
model's last start; the 503 carries it as headers, and a job keeps it as
progress {state: waiting_gpu, held_by, eta_seconds}.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 22:25:27 +02:00
Ilker ManapandClaude Opus 5.5 cf2edfd995 A model evicted for being idle is no longer wanted
CI / test (push) Successful in 8m58s
flux evicted to make room for triposr was placed again two seconds later:
still on the wanted list, and first in the catalog, it took back the card
it had just given up, and the 3D job went on waiting. Its last replica
evicted, it leaves the list until it is asked for again.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 21:11:40 +02:00
Ilker ManapandClaude Opus 5.5 d0f7b1fc4f A model waiting for an on-demand one to step aside is waiting, not unplaceable
A 3D job asked for straight after a picture, on a cluster with one card,
failed at once with "cannot be placed": flux held the card, too freshly
started to evict. The cards coming back when an on-demand model goes idle
makes it a wait, so the request is told to retry and the job waits.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 20:59:59 +02:00
Ilker ManapandClaude Opus 5.5 b743b11c54 A picture is made ready for a 3D model; a model asleep is listed asleep
LLMGWPrepareForMesh, llmgw's own ComfyUI node, written by the gate into
every replica's custom_nodes from the binary: TripoSR's own preparation
(tsr/utils.py) — the background taken away, by the picture's alpha where
it has one and by rembg (MIT; u2net, Apache 2.0) where it does not, the
object cropped to its bounds, set square at a ratio of the side (0.85),
on grey. A picture sent as it was had a white or black shell round the
object, whatever was behind it made into the mesh.

/v1/models listed only models with a replica up, so one that loads when
asked — triposr, flux-schnell — was invisible to the clients that would
have asked for it. Those are listed now, with loaded: false; every entry
says loaded.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 20:43:20 +02:00
Ilker ManapandClaude Opus 5.5 fd3c7bda32 The client API described in OpenAPI, and both APIs in Swagger UI
CI / test (push) Failing after 6m12s
/v1/openapi.json is the API clients use — OpenAI's endpoints, Anthropic's,
and what llmgw adds — in OpenAPI 3.1, generated from internal/clientapi's
route table by the generator the admin API's document comes from, now
told each document's title, server, token and error. A test reads the
router's mux.HandleFunc calls and refuses a route described and not
mounted, or mounted and not described. Asked for before there is a key,
so it needs none.

/docs/api shows it in Swagger UI 5.33.1 (Apache 2.0, served from the
gateway as the page's policy requires): Authorize takes a key, Try it out
sends to this gateway. Staff are offered the admin API's document too,
from /docs/api/admin.json, which nobody else is given.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 20:22:48 +02:00
Ilker ManapandClaude Opus 5.5 41601bc5ef The plan's phase 5 says what is built, what is built but unproven, and what is not
CI / test (push) Failing after 6m22s
The precise KV index and vLLM's LoRA affinity were built in v0.11.2 and
never run against a real vLLM; facility-aware placement in v0.11.2 less
temperature and cooling: marked [~], a mark now explained at the top of
the roadmap. Control-plane HA is done, by leader election on PostgreSQL
rather than Raft, and the admin UI changes things since staff roles
(v0.11.2) and the settings page (v0.14.1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:35:15 +02:00
Ilker ManapandClaude Opus 5.5 cf41adbf25 v1.0.0 in the roadmap is what phase 5 has left, not what it has done
CI / test (push) Failing after 8m29s
Tenancy, prefix affinity and benchmarking were built in v0.9 and after.
What phase 5 of llmgw-plan.md has open is precise KV routing proven on a
real vLLM, the block cost picker, defragmentation across NVLink domains
and prefill/decode disaggregation. Control-plane HA by Raft is left out:
HA is there already, by leader election on PostgreSQL.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:33:10 +02:00
Ilker ManapandClaude Opus 5.5 21239a8488 README: v0.4.0 went out inside v0.5.0, and v0.14.2 and v0.14.3 are listed
CI / test (push) Failing after 8m7s
The table said v0.4.0 was released, which has no tag of its own; the
changelog's table says, rightly, that it was released as part of v0.5.0.
The list of what came since v0.11.0 stopped at v0.14.1.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:32:25 +02:00
Ilker ManapandClaude Opus 5.5 7f320c5f65 v0.14.3: changelog, README, docs for 3D models; a finished job says done
Release / notes (push) Successful in 5s
CI / test (push) Failing after 6m50s
Release / build (push) Successful in 2m6s
A finished job kept the last word heard from ComfyUI, the node running;
it is now said done before the job is marked completed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:22:58 +02:00
Ilker ManapandClaude Opus 5.5 2f54780802 G4: a page that makes 3D models, and ctl 3d
/3d beside /images, while some model makes meshes: a picture chosen, the
job asked for through the same path an API client's takes, its progress
shown (waiting for the card, n ahead; making it, the node and percent),
and the mesh in a viewer — model-viewer 4.4.0 (Apache 2.0, with three.js
and lit), served from the gateway since the page's policy loads scripts
from nowhere else — with a download and the model's licence. The mesh is
served to its owner from /3d/jobs/{id}/file; another project's person is
told there is no such job.

A job made from the portal acted for a key that does not exist: the
worker now rebuilds a session's principal from the account that made it,
while it is active and still in the project the job is charged to.

ctl 3d list|get|cancel, over /api/v1/client-jobs (/api/v1/jobs is the
control plane's own work, which ctl jobs shows).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 19:16:20 +02:00
Ilker ManapandClaude Opus 5.5 dfc2f0bf15 A custom node's own range decides the packages it names
TripoSR's checkpoint loads with transformers 4, and ComfyUI runs 5: of a
package a node names, its range decides, and of everything else what
ComfyUI runs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:54:15 +02:00
Ilker ManapandClaude Opus 5.5 4b68328e3e A mesh's resolution is the model's own: a multiple of 32 from 64 to 2048
TripoSR takes 256 by default; 512, 1024 and 1536 were TRELLIS.2's alone.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:47:52 +02:00
Ilker ManapandClaude Opus 5.5 88781cfcfe A job says how far along it is; a ComfyUI model brings its custom nodes
G2: the gate keeps a websocket open to its ComfyUI under a client id of its
own, puts that id on every workflow it lets through — ComfyUI tells only
the client that queued a workflow how it goes — and keeps the last word on
each: the node running, its step of how many. It answers at
/progress/{prompt_id}. While a job's request waits, the router reads the
queue (queued, n ahead) and the gate (running, node, step, percent) and
writes it into the job, which a client reads back. Only into a job of the
same key: a tag naming another's job writes nothing.

G3, the rest of the plumbing: comfyui.nodes names custom nodes by https
repository and full commit, with the PyPI packages they need. The gate puts
each in the replica's own custom_nodes at that commit and installs the
packages beside the replica, constrained to the versions ComfyUI's
environment already has, which it never changes. The catalog refuses a
branch for a commit and anything but package specs for requirements. An
OBJ a node saves leaves the gateway as a GLB, colours kept, so every mesh
can carry its licence.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:46:44 +02:00
Ilker ManapandClaude Opus 5.5 c87e413360 A ComfyUI model may make meshes: from a picture, with its licence inside
G3, the part that needs no model: a ComfyUI model's comfyui.output (or
external.output) says pictures or meshes. A request for a 3D model — a
job at /v1/3d/generations — carries the picture as base64; at the replica
it is uploaded to ComfyUI's input directory (the gate lets /upload/image
through), the workflow is filled in with its name, the seed and the
resolution, and the mesh the workflow saves comes back with its type,
which is the file the job keeps. comfy.Client reads every list of files an
output names, not "images" only, and types a file by its extension where
ComfyUI sends octet-stream.

A model's license (name, url, notice) is said in /v1/models and in the
job, and written into a GLB's asset.extras, since the file travels without
the API; a mesh it cannot be written into is refused. per_mesh in
auth.pricing, usage.meshes in the rows, the sums and the export
(migration 0045). A picture asked of a mesh model, or a mesh of a picture
model, is refused saying which it makes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 18:30:52 +02:00
Ilker ManapandClaude Opus 5.5 ec233d3586 Merge admission-bulk into jobs-3d
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 17:35:39 +02:00
Ilker ManapandClaude Opus 5.5 63198f2753 A profile may load weights of its own
The catalog had one source a model, so an embedding model served by vLLM
from its repository on a card and by llama.cpp from a GGUF on CPUs had its
entry swapped by hand halfway through the metallurgy import. A profile's
source is what its engine loads and placement looks for; the model's is
the default, and what updates and canaries move. ctl model add checks
every source, the GGUF check reads each llama.cpp profile's, and ctl
weights collects none a profile loads. An empty source leaves a profile's
fingerprint as it was, so running replicas do not read as stale.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 17:15:38 +02:00
Ilker ManapandClaude Opus 5.5 497e4b24a1 A band may wait longer than the rest; a busy refusal says when to return
A knowledge-base import sends far more than the cluster can take at once,
and every band waited the same 30 s. A band now has its own queue_ttl, so
one for bulk work waits behind people instead of being turned away.

And a request turned away because the cluster stayed busy — the queue
full (503) or waited as long as its band may (504) — now carries
Retry-After: 10. Without it llmgw-kb, which retries only what says when
to, counted each such passage as failed; that is what turned parts of the
metallurgy import away.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 16:11:09 +02:00
Ilker ManapandClaude Fable 5.1 b51cc3cf92 A 3D model is a job: queued with an id, asked after, its result a file
POST /v1/3d/generations answers 202 with a job; GET says its status,
progress and result file; cancel stops it in the engine's queue too. The
request is kept as sent and replayed by whichever gateway holds the lease
through the path a client's request takes, so it is charged and judged
like any request (tagged job=<id>), and a model still loading is waited
out for as long as the job has. Migration 0044. Nothing makes 3D models
yet; the door refuses every model until the ComfyUI adapter learns meshes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 16:04:11 +02:00
Ilker ManapandClaude Fable 5.1 baaaa4e019 The game is sold in the EU, so Hunyuan3D is out and TRELLIS.2 is first
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:05:53 +02:00
Ilker ManapandClaude Fable 5.1 cb9149bce3 A mesh carries the licence it was made under
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 15:01:20 +02:00
Ilker ManapandClaude Fable 5.1 5ed18cc4c4 A 3D model is a job, asked after, not a request held open
Section G of the backlog: a jobs table of its own with a leased worker,
progress read from ComfyUI's websocket by the gate, the ComfyUI adapter
widened from pictures to meshes with custom-node installs, and the pages.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-10 14:57:13 +02:00
Ilker ManapandClaude Opus 5.5 9b7ceabd5b A store moved to PostgreSQL leaves notify_sent behind
When notifications were last sent is not worth carrying: at worst one is
sent again an hour early.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 14:48:20 +02:00
Ilker ManapandClaude Opus 5.5 39bb8ee557 Two control planes send a notification once
Each kept its own hour of quiet per event, so a budget spent on a team
both control planes served was announced by each. An event is now claimed
in the store before it is sent (notify_sent, migration 0043; one statement,
so two asking at once cannot both win), and only the claimer sends it. A
store that cannot be asked sends it anyway.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 13:36:08 +02:00
Ilker ManapandClaude Opus 5.5 8d7b96621e A sign-in through the identity provider may end on either control plane
The cookie that carries a sign-in across the provider was signed with a
key each process made for itself, so with two control planes behind a
balancer a callback reaching the other one was refused as tampered — half
of all sign-ins. The key is now the store's oidc-state secret, sealed like
the others and the same on every control plane.

Changelog: the fixes since v0.14.2.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 11:33:41 +02:00
Ilker ManapandClaude Opus 5.5 eaea671f23 The usage sums and exports count pictures
The five-minute, hourly and daily sums gain images (migration 0042), and
the CSV and JSON exports a column for them: once request rows were past
their retention, an export said what pictures cost but not how many there
were. The pictures made before are added to the sums from their rows.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:36:19 +02:00
Ilker ManapandClaude Opus 5.5 bb40d22eb8 An engine's output goes to a file, which an agent restart cannot break
Engines wrote to a pipe of the agent's. An agent that restarted and
adopted them left that pipe with no reader, and an engine's next write
past its buffer failed with EPIPE — in a Python engine, inside whatever it
was reporting on. Each process now writes to its own file in the agent's
work directory (0600), followed into the shard's log; an adopted engine's
file is read from the start, so its output from before the restart is in
its log too. A file past 64 MB is emptied in place; the files go with the
shard.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 10:25:04 +02:00
Ilker ManapandClaude Opus 5.5 9e7618c073 v0.14.2: changelog and README
Release / notes (push) Successful in 4s
Release / build (push) Successful in 2m3s
CI / test (push) Successful in 8m44s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 02:12:08 +02:00
Ilker ManapandClaude Opus 5.5 5967e93187 ComfyUI's output goes through the gate, which keeps reading it
ComfyUI wrote straight to the agent's pipe. An agent that restarts and
adopts the replica leaves that pipe with no reader, and ComfyUI's next
progress bar failed with a broken pipe, failing the workflow. It now
writes to a pipe of the gate's, which reads for as long as ComfyUI writes
and passes on what it can.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 01:54:39 +02:00
Ilker ManapandClaude Opus 5.5 e344db5fa5 An installed ComfyUI says its version and its torch's
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 01:41:11 +02:00
Ilker ManapandClaude Opus 5.5 fe41b5eff3 ComfyUI is installed by the agent, from its source checked against a digest
engines.comfyui in the cluster's settings has every NVIDIA node build
ComfyUI: the release's source archive from GitHub, checked against the
digest the recipe holds — GitHub publishes none for a tag's archive, so a
version without one is refused — unpacked beside a Python environment
whose dependencies are its requirements file resolved as PyPI stood the
day after the release. torch is PyPI's CUDA 13 build, or PyTorch's CUDA
12.9 one on an older driver, and the install is checked on the card.

A requirements file that names anything but PyPI packages is refused.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 01:31:22 +02:00
Ilker ManapandClaude Opus 5.5 c31bac8d4b An awake on-demand model survives a restart of the control plane
A control plane that has just started or become leader has heard no
requests, and read the on-demand replicas its nodes report as wanted by
nobody: it drained every one of them at every failover. An adopted replica
of an on-demand model now gets a whole idle timeout from its adoption.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 01:10:44 +02:00
Ilker ManapandClaude Opus 5.5 b66dd9e6a7 An on-demand model that is up reads as ready, not as not placed
ctl models said "not placed" for a model with min 0 woken by a request and
serving; it now says "ready (on demand)". And the docs: pictures on the
cluster's own cards, with engine comfyui behind its gate.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 01:01:55 +02:00
Ilker ManapandClaude Opus 5.5 354461d3bf The gate makes the directories ComfyUI dies without
ComfyUI lists its base directory's custom_nodes before anything else and
exits when there is none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 00:55:23 +02:00
Ilker ManapandClaude Opus 5.5 fde1501e8a ComfyUI as an engine of the cluster's own
A model whose profiles run engine comfyui is placed on a node's card like
any other, and makes pictures there. ComfyUI asks for no key, so the agent
starts it behind llmgw comfyui-gate: ComfyUI on the loopback, the gate on
the replica's port demanding the replica's key and letting through only
what the router asks — queueing, waiting, fetching, cancelling. The gate
answers the agent's /health and /v1/models, shows ComfyUI the fetched
weights by their own names, and gives each replica its own base directory.
ComfyUI runs as the gate's child, so its card memory is the replica's.

A node offers the engine when agent.yaml names its ComfyUI
(engines.comfyui.dir). The model's comfyui: block names the workflow and,
for a source that is a set of files, the checkpoint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 00:39:10 +02:00
Ilker ManapandClaude Opus 5.5 57270eebcb The pictures page shows a batch in the order it was made
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 23:04:52 +02:00
Ilker ManapandClaude Opus 5.5 698f82cc12 The pictures page is a session page, not asked for an API key
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 22:32:57 +02:00
Ilker ManapandClaude Opus 5.5 62db7c5586 A page that makes pictures, beside the chat
/images offers the models that make pictures, and the chat no longer
offers them. Like the chat, the browser holds a session and never a key;
the page's request is rebuilt from its own fields and sent down the path
every /v1/images/generations request takes. Size, how many, seed, steps
and a negative prompt; each picture shown with its seed, a download link
and a way to make it again. Nothing is kept but the usage. The menu
offers the page while some model makes pictures.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 22:24:43 +02:00
Ilker ManapandClaude Opus 5.5 4a4fe8fa16 Pictures are priced by the picture
auth.pricing takes per_image, charged on top of a prompt's tokens; a model
priced only per picture is not free, so a spent budget still stops it.
Each usage row keeps how many pictures were made (migration 0041), and
ctl request and the admin API's request view show them.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 21:48:47 +02:00
262 changed files with 26737 additions and 842 deletions

No files matched your search

+422 -10
View File
@@ -386,10 +386,9 @@ portal is not done — the page shows; the console and ctl change.
tenant-admin / billing; `ctl audit`. Depends on A5 for a real actor.
2. **Done (branch `features-c`):** OIDC sign-in (`auth.oidc`, PKCE,
verified email, roles and memberships from groups, `no_passwords`) and
SCIM 2.0 (`/scim/v2` Users and Groups). Left: the sign-in cookie is
signed with a per-process key, so with two control planes behind a
balancer the callback must reach the one that started it (sticky
sessions) until the key comes from the store. **OIDC/SSO login (M), then SCIM (M).** `internal/account/service.go`,
SCIM 2.0 (`/scim/v2` Users and Groups). The sign-in cookie's key comes
from the store (2026-10-10), so a callback may reach either control
plane behind a balancer. **OIDC/SSO login (M), then SCIM (M).** `internal/account/service.go`,
`internal/web/account.go`; map IdP groups to org/team/admin.
3. **Done (branch `features-c`):** key lifetime limits (gateway, org, team,
project), `keys rotate --overlap`, kinds and expiry in `keys list`.
@@ -405,8 +404,9 @@ portal is not done — the page shows; the console and ctl change.
lost, budget spent, update available; signed JSON or Slack; retries,
hourly de-duplication), `ctl notify test`, Alertmanager receiver
templates. Left: `server init` and the console do not yet ask for a
receiver — the templates are filled in by hand; each control plane sends
budget events on its own, so two behind a balancer may both send one.
receiver — the templates are filled in by hand. Events are claimed in
the store before they are sent (2026-10-10), so two control planes send
each once.
**Alerting out of the box (S).** `deploy/monitoring/alertmanager.yml`
ships `receiver: none`; add Slack/email/webhook/PagerDuty receiver
templates driven by `server init` or the console; llmgw-native webhooks
@@ -1091,16 +1091,428 @@ at a third of its CPUs.
FlashAttention and ubatch 1–2k; f16 no better. vLLM fp16 (`--runner
pooling`) 6,200 direct, 4,700–4,900 through the gateway in the import,
and its vectors match llama.cpp's (cosine 0.9995). The 103,579 chunks
took 3 h. Wanted: an embedding model's GPU profile on vLLM and CPU
profile on llama.cpp, which needs a source per profile (the catalog has
one per model; the import swapped the entry by hand), and an admission
queue limit that suits bulk imports (30 s turned some away).
took 3 h. Done (2026-10-10, branch `admission-bulk`): a source per
profile, so an embedding model's GPU profile runs vLLM and its CPU
profile llama.cpp; a band's own `queue_ttl` for bulk imports, and
`Retry-After` on a busy refusal, which is what had turned passages away
(llmgw-kb retries only what says when to).
- Retrieval on 40 questions written from the corpus, document in the top
5: bge-m3 31/40 (Turkish questions 13/21), Qwen3-Embedding-4B 37/40
(18/21), with its query prefix 36/40 and first place 31/40 instead of
28 (MRR 0.738 → 0.789 → 0.838). Next: translating the question with the
chat model, and a reranker.
## G. Jobs: 3D generation through ComfyUI (2026-10-10)
A request for a 3D model takes minutes on the one 24 GB card, and the card has to be
emptied of the chat model first. That is not a request to hold a socket for
— `internal/router/activate.go:46` already says so — it is a job: queued,
answered with an id, asked after, its result downloaded. The job machinery
is built first and tested without a GPU; the ComfyUI generalisation and the
custom-node install come after. Decided 2026-10-10: a `jobs` table of its
own rather than a one-line batch (the JSONL shape would be artificial and
has nowhere for progress), and progress is shown, not just a status.
Which models (decided 2026-10-10): the assets go into a game sold on
Steam, worldwide, the EU included, so the licence of the output decides
before the quality does. Hunyuan3D (every version) is out: Tencent's
licence excludes the EU, UK and South Korea, and whether a game shipped
there with its outputs is "use in the Territory" is not a question to
leave to a court. In, in this order: **TRELLIS.2** shape (MIT; the best
geometry; its own texture baking goes through nvdiffrec and nvdiffrast,
both under NVIDIA's non-commercial source licence, so that stage is not
run), **Step1X-3D** (Apache 2.0, StepFun; shape and texture in one, the
EU-safe textured path — its ComfyUI node and VRAM need are to be
checked), **Direct3D-S2** (MIT, DreamTech; high-detail shape, has a
ComfyUI node) if TRELLIS.2's detail falls short. Not in: TripoSR and
InstantMesh (permissive but superseded), Stable Fast 3D (Stability's
community licence, revenue-capped; served all the same since 2026-10-10
at the user's request, see G3), SAM 3D Objects (Meta's own licence,
to be read before it is considered). Steam's disclosure form (changed
2026-01-16) lists pre-generated 3D models as content that must be
declared on the store page: the licence notice G3 writes into the GLB is
what the studio answers that form from.
### G1. Jobs: store, worker, endpoints (M) — done 2026-10-10 (branch `jobs-g1`)
Built as written, with two choices: a job found running after its gateway
died is run again from the start (the prompt id column is there for G2 to
follow the same work instead), and the request is validated at the door
only as far as costs nothing — key, model, allowlist, the request's shape
— with limits, guardrails and admission applied when the worker replays it,
said as the job's error. The result file's `content_type` is the job's; the
files endpoint still serves every file as an octet stream.
- Store: `jobs` (id, owner key/principal, kind, model, status, input
(JSON), progress (JSON: queue position, percent, step), result file id,
error, created/started/finished, lease owner/until), migrations for
sqlite and postgres beside `internal/store/batches.go`, whose
claim/renew/release lease pattern is copied, not shared: a job is one
unit of work, a batch is many lines.
- Worker: `internal/router/jobworker.go` after `batchworker.go`. Any
gateway on the store claims a queued job, asks for the model with
`activator.Want` and waits as long as it takes (no `ActivationWait`
bound: nobody is holding a connection), runs it, stores the output
through the files store, writes usage and price, marks it done. A
gateway that dies mid-job loses its lease and another one picks it up;
the ComfyUI prompt id is in the job row so the second gateway asks
after the same work rather than queueing it again.
- Endpoints: `POST /v1/3d/generations` validates like any inference
request (key, allowlist, limits, admission, guardrails on the prompt),
writes the job, answers 202 with `{id, status: "queued"}`;
`GET /v1/3d/generations/{id}` returns status, progress and, when done,
`output_file_id` and the usage; `POST .../{id}/cancel`; the file comes
from `GET /v1/files/{id}/content`. Owner-scoped like batches. Results
are kept for the batch files' retention and deleted with them: a
departure from `images.go`'s "kept nowhere", because a mesh is a
download, not a JSON field; the retention is the bound.
- Done when: a job survives the gateway that took it being killed, a
cancelled job leaves ComfyUI's queue (`comfy.Client.cancel`), two
gateways never both run one job (test with the lease), and the usage
row carries the job's price.
### G2. Progress from ComfyUI (S, after G1) — done 2026-10-10 (branch `jobs-3d`)
Built as written: the gate's websocket under its own client id, the
client_id of every /prompt rewritten, /progress/{prompt_id} on the gate,
the router reading it and /queue into the job (only into a job of the same
key). Left: a node that reports no steps (TripoSR's) shows its name and no
percent; a vanished replica is said by the job failing, not by progress.
The gate (`internal/comfy/gate.go`) passes seven paths and no websocket.
Rather than open `/ws`, the gate subscribes to ComfyUI's own `/ws` once,
with `golang.org/x/net/websocket` (already a dependency), under a client
id of its own, and rewrites the `client_id` of every `/prompt` it
forwards to that one. It keeps the last `executing`, `progress`
(value/max/node) and `execution_error` message per prompt id and serves
them at `GET /progress/{prompt_id}` on the gate; the router polls that
beside `/history` and `/queue` and writes queue position, percent and the
current node's class into the job's progress. The gate's buffer is
bounded (the last N prompts) and the websocket reconnects. Done when a
3D job shows "queued, 2 ahead" then "running 40%, step 12/30" in
`ctl jobs` and on the account page, and a job whose replica vanished
says so within one poll.
### G3. ComfyUI beyond pictures: 3D models and custom nodes (M, after G1) — done 2026-10-10 (branch `jobs-3d`), with TripoSR
Built: output kinds, image upload through the gate, every output list
fetched, OBJ to GLB, licence in /v1/models, the job and the GLB, per_mesh
and usage.meshes (migration 0045), custom nodes cloned at their commit by
the gate with packages beside the replica (a node's named packages at its
range, the rest at ComfyUI's versions). Not as written: the model. On the
PoC's Turing card TRELLIS.2 cannot run — it needs Meta's gated DINOv3,
its published background remover (RMBG-2.0) is non-commercial, and its
sparse attention and Direct3D-S2's need flash-attention (Ampere and
newer). TripoSR (MIT) proves the chain on cuda01: a .glb from a picture
in about 30 s. Left: background removal before TripoSR (BiRefNet, MIT,
as a node), TRELLIS.2 or Direct3D-S2 on an Ampere card with an ungated
encoder, a missing node refusing the replica by name.
Added 2026-10-10 at the user's request, beside the order above: **Stable
Fast 3D** as `sf3d` (Stability AI Community License: free under US$1 M
annual revenue, an enterprise licence above, "Powered by Stability AI"),
the textured mesh that runs on Turing: about 1 s a mesh warm in fp16,
UV-mapped GLB with its texture, 18 GB of the card at peak. Its weights are
gated, so they were fetched once with the user's token into
/srv/llmgw-comfy/stable-fast-3d and linked into the engine user's
Hugging Face cache, which its loader node reads by repository name; no
token is on the machine at runtime. Its own save node returns the GLB as
base64, which the gate cannot fetch, so `LLMGWSaveMesh` writes the file;
`LLMGWPrepareForMesh` now also gives the object's mask. Left: its two
CUDA extensions (texture_baker, uv_unwrapper) are not on PyPI, so they
were built on cuda01 and installed into the engine's venv by hand; a
custom node's requirements need a way to name a local build, or the
engine install must carry them, before a customer site gets this. And
rembg's default session now fetches BRIA RMBG-2.0 (non-commercial):
every workflow must name its rembg model (ours say u2net).
- Catalog: a model makes `pictures` or `meshes` (`MakesImages` becomes an
output kind; `ServesComfyUI` stays). 3D weight folders in the gate's
`modelFolders`; the workflow's placeholders gain `image` (the input
picture, base64 in the request, written to the replica's `input/` by
the gate through a new `POST /upload/image` the gate allows — or a
base64-loading node, whichever TRELLIS.2's wrapper wants).
- `comfy.Client.fetch` reads every output key, not `images` only, and
fetches each file named by `/view`; `Image` becomes `Artifact` with its
content type (model/gltf-binary, application/octet-stream for .obj).
- Custom nodes: a model (or the engine config) names git repositories
and a pip requirements list; the agent's provision step clones them
into the replica's `custom_nodes/` (the gate's `--base-directory` is
where ComfyUI looks, not the install directory) and installs the
requirements into the engine's venv with the same PyTorch index and
lock discipline as A8. A replica with a missing node refuses to start
with the node's name in the error rather than ComfyUI's traceback.
- Licence of the output (decided 2026-10-10: it matters). A catalog model
carries `license: {name, url, notice}`; a job's answer, `/v1/models`
and the portal page show it beside the result, and the mesh's GLB
`asset.extras` carries the notice too, since the file travels without
the API. What was checked on 2026-10-10: TRELLIS.2 weights are MIT and
its outputs free, but its texture baking depends on nvdiffrast, whose
NVIDIA Source Code License is non-commercial — a commercial service
must render textures another way or skip that stage. Hunyuan3D 2.0 and
2.1 claim no rights in outputs, but the licence excludes the EU, UK and
South Korea, needs Tencent's permission above 1 M monthly active users,
forbids using outputs to train other models, and asks for "Powered by
Tencent Hunyuan"; the notice ships with any distribution. TripoSR is
MIT, InstantMesh Apache 2.0. Every one of them trained on Objaverse,
some of whose objects are CC-BY-NC: a provenance question no licence
text settles, to be said in the notice rather than hidden.
- Pricing: `per_image` stays; `per_mesh` beside it in `accounting.Price`,
`Outcome.Meshes`, the usage sums and exports (as eaea671 did for
pictures).
- Done when: TRELLIS.2 on cuda01 with `scaling: {min: 0,
idle_timeout: 10m}` makes a .glb from a picture through G1's endpoint
with nvdiffrast and nvdiffrec not installed in its venv (the licence
is kept by the install, not by a note), the GLB carries the MIT
notice,
the card is free again after the idle timeout, and the same request
against flux-schnell is refused as "makes pictures, not meshes".
### G4. Portal and console (S, after G2) — done 2026-10-10 (branch `jobs-3d`)
Built: /3d with model-viewer served from the gateway, progress, download
and licence; `ctl 3d list|get|cancel` over /api/v1/client-jobs (ctl jobs
was taken by the control plane's own work). Left: the console's Jobs view.
A 3D page beside `/images` (upload a picture, watch progress, download
the .glb, a viewer with `<model-viewer>` from the allowed CDNs), `ctl
jobs list|get|cancel`, the console's Jobs view. The page sends down the
same path as the API, like `imagesSend` does.
## H. ComfyUI as a platform (written 2026-10-11)
G made one model at a time work: flux for pictures, TripoSR and SF3D for
meshes, each with its own endpoint, its own request shape and its own
replica. The user's direction (2026-10-11): llmgw is a generic platform,
and should serve whatever ComfyUI can run — pictures, edits, video,
audio, meshes, upscaling, segmentation — as data in the catalog rather
than code per model. Two decisions shape everything below:
- **The licence is metadata, not a filter.** llmgw does not leave a
model out because of its licence. The licence travels with the model
and with every file it makes; what a site will not serve is that
site's policy (H6). The game studio's EU constraint is one such policy,
not a decision the code makes for everyone.
- **The install fits the hardware.** The PoC has one Turing card; other
sites will have Ampere, Ada and Hopper. A catalog entry must be
writable once and placed sensibly anywhere: fp16 on Turing, bf16 or
fp8 where the card has them, flash-attention models only where it
runs, CUDA extensions built for the card that runs them (H2, H4).
The first set of models proves the structure at its corners rather than
by licence: Qwen-Image-Edit (several weight files, two pictures in),
Wan 2.2 small (video out, a long job), ACE-Step (audio out), SF3D (a
package not on PyPI, gated weights), Hunyuan3D (a licence that excludes
the EU, which H6 must be able to say no to).
### H1. A workflow says what it takes and makes (M) — built 2026-10-11, not yet tried on the PoC
Built as written, with three choices: a run answers with one file, the
first of its output kind (several outputs as one zip is left); the
workflow's placeholders are checked against the inputs when a run is
served, since the catalog never reads the workflow file; and video,
audio and files count as nothing in usage until a per-run price exists
(per_image and per_mesh still count pictures and meshes).
- Catalog: `comfyui.inputs` — a list of `{name, type, required, default,
min, max, choices, max_mb, doc}`, type one of text, int, float, bool,
choice, image, mask, audio, video, file. Each is a placeholder of the
same name in the workflow. The template's own placeholders are checked
against the list when the catalog is built: one the workflow uses that
no input or built-in names is refused there, not at the first request.
- `comfyui.output` gains `video`, `audio` and `files` beside `pictures`
and `meshes`; `files` takes whatever the workflow saves. `comfy.Image`
becomes `comfy.File` and `typeByName` knows mp4, webm, gif, wav, flac,
mp3, ogg.
- Endpoint: `POST /v1/workflows/runs` with `{model, inputs: {...}}`, a
job like G1's (`GET /v1/workflows/runs[/{id}]`, `.../cancel`). A file
input is base64, a data URL, or a `file-…` id from `/v1/files` for
what is too big to send inline (video, long audio). Files go into the
replica's input directory through the gate's `/upload/image`, which
ComfyUI uses for any file. `/v1/images/generations` and
`/v1/3d/generations` stay as they are, as fixed shapes over the same
machinery.
- The models list (`/v1/models`) gives each ComfyUI model its inputs and
output kind, so a client can build a form without reading the catalog.
- Done when: the existing flux, triposr and sf3d entries work unchanged,
and one model with a picture and a text input (Qwen-Image-Edit) runs
through `/v1/workflows/runs` with a 400 for a missing required input
before any job is written.
### H2. Profiles that fit the hardware (M) — built 2026-10-11
Built as written. A profile by need is left out of placement until the
cluster's cards are known: with no class, placement would have read it as
a CPU profile. A profile's own `source` is loaded under its own file name
by the workflow's `{{checkpoint}}`.
- A profile may say `gpu: {min_compute_capability, min_memory_gb}` in
place of `gpu_class`. When the reconciler places a model, such a
profile stands for every GPU class in the cluster that meets it, at
the profile's priority: the catalog function that turns profiles into
placement's view takes the cluster's classes (class, compute
capability, memory) and returns one concrete profile per class that
fits, named `<profile>@<class>`. Placement itself is unchanged: it
still sees exact classes, and a tensor-parallel group still never
mixes them.
- A ComfyUI profile may carry its own `comfyui` block (workflow, nodes,
checkpoint) over the model's: the fp8 workflow on compute 8.9 and up,
the fp16 one on 7.5. A Turing card is not an error, it is the profile
that fits it.
- Done when: one catalog entry with an "ampere+" profile (min 8.0) and a
"turing" fallback places the fallback on cuda01, and the same entry,
in a test with an A100 in the inventory, places the other.
### H3. Environments: which models can share a replica (S, the catalog side)
- `comfyui.environment: <name>` puts a model in a named environment.
Models in one environment must agree on every custom node they both
name (same repository, same commit) and their requirements must
resolve together; the catalog refuses an environment that cannot, with
both models' names. Models with no environment are each their own, as
today.
- `Catalog.Environments()` returns each environment with its models and
the union of their nodes: what one replica of it must install.
- Placement and eviction of a replica that serves every model of its
environment (so flux, SF3D and Qwen-Image-Edit stop handing the card to
each other with a 30 s cold start each time) is the other session's
work (llmgw-b7), built on this.
### H4. Custom nodes with packages that are built, not downloaded (S) — built 2026-10-11
Built as written, with `uv build --no-build-isolation` against ComfyUI's
Python, the card's capability asked of torch in the replica (so
CUDA_VISIBLE_DEVICES is the card it will run on), and wheels kept under
the engines' cache by commit, `sm<arch>` and directory. Also
`comfyui.huggingface_repo`: a node that loads its weights by repository
name (SF3D's) finds them in the engines' Hugging Face cache, linked by the
gate, so no token is on the node.
- `nodes[].build: [subdir, ...]`: directories of the node's repository
that are Python packages with C++/CUDA extensions (SF3D's texture_baker
and uv_unwrapper). The gate builds each as a wheel with the engine's
Python, `TORCH_CUDA_ARCH_LIST` set from the compute capability of the
card it runs on, and installs it into the replica's own `site` like
any other requirement. Built wheels are kept under the engine's home by
node commit, subdirectory and architecture, so the second replica on
the same kind of card does not build again.
- A node needing nvcc on a node without it refuses the replica with that
said, not with a compiler's traceback.
- Done when: SF3D runs on cuda01 with nothing installed into the engine's
venv by hand (the hand install of 2026-10-10 removed first).
### H5. Licence on every output (S) — done 2026-10-14: the whole licence in GLB asset.extras and PNG iTXt; video and audio not yet
- `License` gains `id` (SPDX where there is one, else a lowercase name:
stability-community, tencent-hunyuan-community), `commercial` (unset is
not said), `excluded_regions` (ISO 3166 codes, `EU` for the union),
`revenue_cap_usd` and `attribution` ("Powered by Stability AI"). All
optional but the name. Done 2026-10-11 in the catalog, so H6 can match
on `id`.
- Written into the file where the format has a place for it: glTF
`asset.extras.license` (as now), PNG an `iTXt` chunk named `license`
(the workflow's own text chunks are still stripped), MP4/WebM and the
audio formats in their metadata where ffmpeg is at hand on the router,
otherwise beside it in the job's answer only — said in the docs.
### H6. Licence choices (S) — done 2026-10-11 (4bbac08), live on the PoC
The user asked (2026-10-11) for the choice to be a person's: on their
profile they pick the licences they will not use, and their keys and
portal session neither see those models in `/v1/models` nor may call
them (403, saying which licence). Matched on `license.id`, and on
`commercial` / `excluded_regions` where a choice is put that way. Its
store table is migration 0046. A site-wide or team-wide default can sit
on top of it later.
### H7. A job worker per model, not one for all (S) — done 2026-10-14: one job per model at a time, eight in all
The worker runs one job at a time: a job waiting for the card holds up a
job for another model that could run now (seen on the PoC, 2026-10-10).
Jobs are claimed and run concurrently up to a limit per model (or per
environment, after H3) and a cluster-wide cap, with the lease and
leader-only rules of G1 kept.
### H8. Presets that add themselves (S) — done 2026-10-13 (v0.14.5)
flux-schnell and triposr come with llmgw (`ctl model add <preset>`), and
the leader adds them on demand where a node fits one, the cluster installs
ComfyUI and the catalog makes nothing of that kind (`presets.auto`); a
preset removed is declined and not added back. The cluster page and the
console show and add them.
### H9. Small things learned on the PoC (2026-10-14)
- Done: `albedo_match` on sf3d (its delighting made dark objects light);
`on_demand.evict_after_idle` / `min_lifetime` as live settings; an
upgrade that leaves nodes behind says which; `ctl model serve` serves an
embedding model for embedding; refreshes wait while a form is filled in.
- Done 2026-10-15: llmgw's `/mcp` with picture, 3D, VLM and workflow tools,
so a client needs no server of its own (asked by the game team); the
shared ComfyUI replica (H3's placement side).
## 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`.
+469 -3
View File
@@ -18,13 +18,479 @@ 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) |
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
| 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
Picture and 3D models that add themselves where a card fits them, with a
card on the portal and a screen in the console to see and add them; a
mesh's colour brought to the picture it was made from; and the whole
licence written into every mesh.
**Upgrading.**
- The store gains a table (migration 0047: preset_state); nothing to do.
- **`presets.auto` is on**: once upgraded, the leading control plane adds
flux-schnell and triposr, on demand, where a node's card fits one, the
cluster installs ComfyUI (`engines.comfyui`) and the catalog makes
nothing of that kind. Nothing is fetched until somebody asks for one.
Turn it off with `llmgw ctl model presets auto off`.
- **Nodes** need this release for `albedo_match` (it is in the save node
the gate puts in each replica); a ComfyUI replica takes it when it
starts again.
### Added
- **A mesh's base colour brought to its picture's**: `LLMGWSaveMesh` takes
`albedo_match` with the picture and its mask, and scales the texture in
linear light to the object's brightness in the picture. Stable Fast 3D
takes the light out of what it sees and read a dark basalt (0.17) as a
light stone dimly lit (0.67); matched, it is 0.17. On sf3d it is the
`albedo_match` input (off unless asked).
- **Picture and 3D models by themselves**: once a node has a card that fits
flux-schnell or triposr and the catalog makes nothing of that kind, the
leading control plane adds the preset on demand — so a new cluster's
portal has its Images and 3D pages without a definition written — where
the cluster installs ComfyUI. `presets.auto` turns it off; a preset
somebody removes is not added back. `GET /api/v1/presets` says how each
stands and why one is not added; `POST /api/v1/presets/{name}` adds one
(and sets `engines.comfyui` if the cluster has none); `PUT
/api/v1/presets/auto`. Each automatic addition is in the audit log.
- **Presets on the portal and in the console**: the cluster page's
**Pictures and 3D models** card lists each preset — what it makes, the
classes it fits here, whether and by whom it was added, why the leader
has not — with an **Add** button that says first when it would install
ComfyUI and restart the control planes, and the switch for `presets.auto`.
The console's models screen shows the same; **A** adds one, **P** flips
the switch. `ctl model add PRESET` and `ctl model presets` go through the
API (and `ctl model presets auto on|off`), falling back to the entry as
v0.14.4 sent it on an older control plane.
### Fixed
- A mesh carried only its licence's name, URL and notice; it carries the
whole licence as `/v1/models` gives it — id, commercial use, excluded
regions, revenue cap, attribution — for a pipeline that sorts assets by
licence.
- A workflow's `{{name|false}}` default filled a BOOLEAN input with the
word rather than a boolean.
## v0.14.4 — 2026-10-13
Any ComfyUI workflow as a job, with typed inputs; picture and 3D models
added by name; profiles that fit whatever card a site has, and ComfyUI on
a V100; licences a machine can match and a person can leave out; a job
waiting for the one card says what holds it. Many fixes to on-demand
models taking turns on one card.
**Upgrading.**
- The store gains a table (migration 0046: user_excluded_licences);
nothing to do.
- **Nodes** need this release for ComfyUI on Volta or Pascal cards (the
cu126 build), for a driver from NVIDIA's open kernel module to be read,
and for a node that loads its weights by repository name (sf3d); a
ComfyUI already installed is kept.
- **Control planes before nodes**: an entry using the built-in `triposr`
workflow, or a `gpu:` need instead of a class, is refused by an older
control plane.
- Models added with `llmgw ctl model add <preset>` take the preset's name;
remove an entry of that name first.
### Added
- **The client API described**: OpenAPI 3.1 at `/v1/openapi.json`, no key
needed, generated from a route table a test holds to the router's own;
and `/docs/api`, Swagger UI (served from the gateway) over it and, for
staff, over the admin API's document.
- **A job waiting for the card says so**: `progress` is
`{"state":"waiting_gpu","held_by":…,"eta_seconds":…}` while another
on-demand model holds it; a direct request's 503 carries
`X-Llmgw-Waiting-For` and `X-Llmgw-Eta-Seconds`.
- **Any ComfyUI workflow, as a job**: `POST /v1/workflows/runs` with
`{model, inputs}`. A model's catalog entry declares its inputs
(`comfyui.inputs`: text, int, float, bool, choice, image, mask, audio,
video, file, with bounds and defaults) and its output (`comfyui.output`:
pictures, meshes, video, audio or files); a request is checked against
them before a job is written, file inputs are base64, a data URL or a
`/v1/files` id, and `/v1/models` lists each model's `workflow` so a
client can build its form. A PNG result carries the licence in an
`iTXt` chunk, a GLB in `asset.extras` as before.
- **Environments**: `comfyui.environment` names ComfyUI models that agree
on their custom nodes and may share one replica; the catalog refuses
one that cannot.
- **Profiles that fit the hardware**: a profile may say `gpu:
{min_compute_capability, min_memory_gb}` instead of `gpu_class`, and
stands for every class in the cluster that meets it, as
`<profile>@<class>`; a ComfyUI profile may carry its own `comfyui`
workflow, checkpoint and nodes, and any profile its own `source`. One
catalog entry then runs fp8 where the card has it and fp16 on Turing.
- **Custom nodes that compile**: `nodes[].build` names directories of a
node that are C++/CUDA packages; the gate builds each as a wheel for the
card the replica runs on, keeps it on the node, and installs it beside
the replica. `comfyui.huggingface_repo` shows a model's weights to a node
that loads them by repository name.
- **A licence a machine can match**: `license.id`, `commercial`,
`excluded_regions`, `revenue_cap_usd`, `attribution`.
- **On-demand models in `/v1/models`**, with `loaded` saying whether one is
up now; a picture is cut out and put on grey before TripoSR sees it
(`LLMGWPrepareForMesh`, rembg when it has no alpha).
- **Licences a person leaves out**: on the profile page, by licence, or by
rule — every licence forbidding commercial use, every licence granting
nothing in a region — so a model under one is neither listed to nor
served for that person's keys and sessions (403 `licence_excluded`).
- **Presets: a picture or 3D model added by name.** `llmgw ctl model add
flux-schnell` or `triposr` adds an entry that comes with llmgw — its
workflow (TripoSR's now built in), custom nodes, packages and licence,
and a profile by card need rather than class — and has the nodes install
ComfyUI when the cluster's settings name none. `llmgw ctl model presets`
lists them; the console's **A** on the models screen offers them beside
a definition file.
### Fixed
- A model waiting for another on-demand model to give up the card failed at
once as "cannot be placed"; it now waits, and a job with it.
- A model evicted for being idle was placed again on the next pass, ahead of
the one it made room for.
- A job or batch leased by a standby control plane, whose router has no
endpoints, waited on "loading" for good; they are served only by the
leader. A job told its model fits nowhere asks again for five minutes
before failing, which an agent re-registering makes true for seconds.
- An on-demand replica adopted after a restart was never idle enough to
evict, so a model waiting for its card waited out its whole idle timeout;
it counts as used at adoption.
- A picture in a prompt was counted as its base64 by the context check, so
one 1280x720 PNG was "138 thousand tokens" and a vision model refused it;
a picture or a sound now counts as about a thousand.
- An on-demand model of a higher class was evicted, idle, for a lower-class
one two minutes into a lull; it keeps the card through its idle timeout,
and a model waiting on it is told so.
- A model removed from the catalog stayed wanted, so added again it loaded
unasked.
- A node that loads its weights by repository name (sf3d) went to Hugging
Face for a gated repository and got a 401: the agent did not hand the
gate `comfyui.huggingface_repo`, so the weights on the node were never
linked into its cache.
- **ComfyUI on a V100.** torch's CUDA 12.8 and newer builds have no Volta
kernels, but its cu129 build was taken to run from compute capability
7.0, so on a V100 both ComfyUI builds were installed, about 10 GB each,
and failed with "no kernel image". Cards older than Turing now get
ComfyUI on PyTorch's CUDA 12.6 torch (build `cu126`); a Turing or newer
node keeps the build it has.
- **The open NVIDIA driver read by its version.** With the open kernel
module, the default since 560, the agent read the driver as "for" and so
as unknown, and tried every engine build whatever driver it needs.
## v0.14.3 — 2026-10-10
3D models from a picture, as jobs: made by ComfyUI on the cluster's cards,
with their progress, their licence written into the mesh, a price per mesh
and a page to make them. Also: a profile's own weights, a band for bulk
work, and fixes for control planes behind a balancer and agents that
restart.
**Upgrading.**
- The store gains tables and columns (migrations 0043–0045: notify_sent,
jobs, usage meshes); nothing to do.
- **Nodes** need this release for their engines' output to survive an
agent restart and for ComfyUI's custom nodes and progress; engines
started by an older agent keep its pipe until they are restarted.
- A 3D model needs a model that makes them (see install.md, "A model that
makes them"); none is added.
### Added
- **Jobs: 3D models asked after, not waited for.** `POST
/v1/3d/generations` answers 202 with a job id; `GET
/v1/3d/generations/{id}` says its status, progress and, when done, the
file its result is in (`/v1/files/{id}/content`); `POST .../cancel` stops
it, in the engine's queue too. The request is replayed by whichever
gateway holds the job's lease through the same path a client's request
takes — key, allowlist, limits, admission, guardrails, accounting — so
it is charged like any request, tagged `job=<id>`, and a model still
loading is waited out for as long as the job has (24 h) rather than
refused. A job survives the gateway that took it; results are kept for
`router.batch.retain_days`. Migration 0044.
- **ComfyUI models that make meshes** (`comfyui.output: meshes`). The
picture is uploaded to the replica's ComfyUI and the workflow's
`{{image}}`; the mesh the workflow saves is the job's file, an OBJ turned
into a GLB. TripoSR (MIT) is the tested model; on Ampere and newer cards
stronger ones can be added the same way.
- **Custom nodes** (`comfyui.nodes`): a repository at a full commit, put in
each replica's own `custom_nodes` by the gate, with its packages
installed beside the replica, never into ComfyUI's environment.
- **A job's progress**: the gate listens to ComfyUI's websocket and the job
says queued and how many ahead, then the node running, its step and
percent.
- **A model's `license`** (name, url, notice), in `/v1/models`, in the job,
on the portal and in the GLB's `asset.extras`.
- **`per_mesh`** in `auth.pricing`; usage rows, sums and exports count
`meshes`.
- **`/3d` on the portal**, beside `/images`: a picture chosen, the job
followed, the mesh in a viewer with a download and its licence; and
**`llmgw ctl 3d list|get|cancel`** for every owner's jobs.
- **A profile's own `source`**: the weights it loads instead of the
model's, so an embedding model can run on vLLM from its repository on a
card and on llama.cpp from a GGUF on CPUs as one model. Placement prefers
nodes holding each profile's weights, `ctl model add` checks every
source, and `ctl weights` collects none a profile loads.
- **A band's own `queue_ttl`** (`router.admission.bands[].queue_ttl`): how
long its requests may wait for room, instead of the admission's 30 s. A
band for bulk work — a knowledge-base import — waits behind people rather
than being turned away.
### Fixed
- **An engine's output no longer breaks when its agent restarts.** Engines
wrote to a pipe of the agent's, which an agent that restarted and adopted
them left with no reader; an engine's next large write failed, and in a
Python engine took the request it was reporting on with it. Each process
now writes to its own file in the agent's work directory, and an adopted
engine's log keeps what it wrote before the restart.
- **Single sign-on behind a balancer.** The cookie that carries a sign-in
across the identity provider was signed with a key of each process's own,
so with two control planes a callback that reached the other one was
refused. The key is now a secret in the store, the same on every control
plane.
- **Notifications sent once by two control planes.** Each kept its own
hour of quiet per event, so a budget spent on a team both served was
announced twice. The control plane that claims an event in the store
(migration 0043) sends it; with the store unreachable it is sent anyway.
- **A request turned away for a busy cluster says when to come back.** A
request that waited as long as it may (504) or found the queue full (503)
had no `Retry-After`, so `llmgw-kb` counted the passage as failed instead
of sending it again; both now say 10 seconds.
- The usage sums and exports count pictures (`images`), as the request
records did; pictures made before are added to the sums.
## v0.14.2 — 2026-10-10
Pictures: OpenAI's `/v1/images/generations`, made by ComfyUI on the
cluster's own cards. The agents install ComfyUI themselves, the gateway
prices pictures by the picture, and the portal has a page to make them.
Also: on-demand models that survive a failover, and knowledge-base
embeddings fixes.
**Upgrading.**
- The store gains a column (`usage.images`); nothing to do.
- **ComfyUI is installed only where asked**: `llmgw ctl config set
engines.comfyui 0.39.2` has every node with an NVIDIA GPU build it (about
10 GB under `/opt/llmgw/engines`). Nodes need this release to install it
or run engine `comfyui`.
### Added
- **Pictures at `/v1/images/generations`**, OpenAI's shape, through the same
key, allowlist, limit, admission and guardrail checks as any request. A
model with profiles on engine `comfyui` is placed on a GPU node like any
other. A ComfyUI server outside the cluster can be an external model with
`api: comfyui` instead. The workflow is a built-in (`flux-schnell`, the
default, or `flux-dev`) or a JSON file in ComfyUI's API format, with
`{{prompt}}`, `{{width}}`, `{{seed}}` and the like where the request's
values go. Requests may also send `seed`, `steps`, `negative_prompt` and
`guidance`. The seed used comes back in `x-llmgw-seed`. Pictures come back
as `b64_json`; ComfyUI's workflow is stripped from each PNG, and a client
that leaves takes its workflow off the queue. `/v1/models` says `images`
for such a model.
- **Engine `comfyui`.** ComfyUI asks for no key, so the agent starts it
behind `llmgw comfyui-gate`. ComfyUI listens on the loopback only. The
gate listens on the replica's port, demands the replica's key, and lets
through only what the router asks. The model's `comfyui:` block names
the workflow and, for a source that is a set of files, the checkpoint.
- **The agents install ComfyUI** (`engines.comfyui`). The release's source is
checked against a digest llmgw holds, since GitHub publishes none, and a
version without one is refused. Its requirements are resolved as PyPI
stood the day after the release, with PyPI's CUDA 13 torch or PyTorch's
CUDA 12.9 one for an older driver, and the result is checked on the card.
A ComfyUI installed by hand is named by `engines.comfyui.dir` in
agent.yaml.
- **A price per picture**: `per_image` in `auth.pricing`, on top of the
prompt's tokens. A model priced only per picture is not free, so a spent
budget still stops it. Each usage row keeps how many pictures were made,
shown by `llmgw ctl request`.
- **`/images` on the portal**, beside the chat, for the models that make
pictures. The chat no longer offers them. The page offers size, how many,
seed, steps and a negative prompt, and shows each picture with its seed
and a download link. Nothing is kept but the usage.
- **An embedding model's `query_prefix`**, put before the question a
knowledge-base search embeds and never before documents, for Qwen3-Embedding
and models trained like it.
### Fixed
- **An awake on-demand model survived no restart or change of leader of the
control plane.** A control plane that had heard no requests read every
on-demand replica its nodes reported as wanted by nobody, and drained them
all. An adopted replica now gets a whole idle timeout.
- `ctl models` said `not placed` for an on-demand model that had been woken
and was serving; it now says `ready (on demand)`.
- A batch of embeddings was refused as one over-long prompt when the
passages together exceeded the context, though each one fit. The longest
input is what has to fit.
- The chat's kept conversation sat in the browser under one key for
everybody, so the next person to sign in on the same browser opened the
last one's conversation. It is kept per account, and signing out clears
what the pages stored.
## v0.14.1 — 2026-10-09
+43 -3
View File
@@ -30,7 +30,7 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
| v0.1.0 | 0 | Skeleton and sim-engine | released |
| v0.2.0 | 1 | Static router (filter/score/pick, streaming proxy) | released |
| v0.3.0 | 1.5 | Admission control (priority bands) | released |
| v0.4.0 | 2 | Agent: GPU/IB discovery, engine supervision | released |
| v0.4.0 | 2 | Agent: GPU/IB discovery, engine supervision | released as part of v0.5.0 |
| v0.5.0 | 3 | Control plane: catalog, placement, reconciler, ctl | released |
| v0.6.0 | 4 | Multi-node placement and fabric awareness | released |
| v0.7.0 | — | Conversations, agent memory, disk management | released |
@@ -40,8 +40,8 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | released |
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine) |
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
| 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
control plane places replicas — including ones larger than a single machine —
@@ -50,6 +50,46 @@ 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
Images and 3D pages; the cluster page and the console show each preset
and add it by hand, and `presets.auto` turns it off. Stable Fast 3D's
colours can be brought to the picture's (`albedo_match`), and every mesh
carries its whole licence.
- **Any ComfyUI workflow, and models by name** (v0.14.4). `POST
/v1/workflows/runs` runs a ComfyUI model's workflow as a job, its inputs
(text, numbers, choices, images, masks, audio, video, files) declared in
the catalog and listed in `/v1/models`. `llmgw ctl model add triposr` or
`flux-schnell` adds a tested model with no YAML; a profile may name the
card it needs instead of a class, and ComfyUI now installs on V100s. A
licence carries what a machine can match, and a person can leave
licences out on the profile page so no key of theirs reaches such a
model. A 3D job waiting for the one card says what holds it and for
about how long.
- **3D models as jobs** (v0.14.3). `POST /v1/3d/generations` answers with a
job, asked after until its mesh is ready to download. ComfyUI makes it on
the cluster's own cards, TripoSR being the tested model. The job says how
far along it is, the model's licence is written into the GLB, and
`per_mesh` prices it. People make them at `/3d` on the portal; `llmgw ctl
3d` shows every owner's jobs. A model profile may also load weights of
its own, and an admission band may wait longer than the rest, for bulk
work.
- **Pictures from ComfyUI** (v0.14.2). `POST /v1/images/generations` is made
by ComfyUI, behind a gate that demands the replica's key, as an engine the
agents install from its source checked against a digest. `per_image`
prices it, and people make pictures at `/images` on the portal.
- **Adapters, and the tenancy tree over the API** (v0.14.1). `llmgw ctl
kb dataset` and `llmgw ctl train` make a LoRA adapter from a collection
on a node's spare GPU memory, kept on the mirror and seen only by its
+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
# }
+6
View File
@@ -96,6 +96,12 @@ func titleOf(src, path string) string {
//go:embed docs/admin-api.openapi.json
var AdminOpenAPI []byte
// ClientOpenAPI is the client API described in OpenAPI 3.1, generated from
// internal/clientapi's route table and served at /v1/openapi.json.
//
//go:embed docs/client-api.openapi.json
var ClientOpenAPI []byte
// EvalSuites are the example quality suites under deploy/eval, which the
// control plane runs by name when its eval directory has none of the name.
//
+648 -1
View File
@@ -29,6 +29,9 @@
{
"name": "capacity"
},
{
"name": "client-jobs"
},
{
"name": "eval"
},
@@ -83,6 +86,9 @@
{
"name": "plan"
},
{
"name": "presets"
},
{
"name": "projects"
},
@@ -280,6 +286,133 @@
}
}
},
"/api/v1/client-jobs": {
"get": {
"operationId": "getClientJobs",
"summary": "Jobs clients asked for — 3D models — of every owner, newest first",
"tags": [
"client-jobs"
],
"parameters": [
{
"name": "status",
"in": "query",
"description": "Only jobs in this status: queued, running, cancelling, completed, failed, cancelled, expired.",
"schema": {
"type": "string"
}
},
{
"name": "limit",
"in": "query",
"description": "The most jobs to answer with; 50 when absent.",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/JobView"
}
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/client-jobs/{id}": {
"get": {
"operationId": "getClientJobsId",
"summary": "One job a client asked for",
"tags": [
"client-jobs"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The job's ID, job_….",
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobView"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/client-jobs/{id}/cancel": {
"post": {
"operationId": "postClientJobsIdCancel",
"summary": "Stop a job, in the engine's queue too",
"tags": [
"client-jobs"
],
"parameters": [
{
"name": "id",
"in": "path",
"required": true,
"description": "The job's ID, job_….",
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/Actor"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/JobView"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/eval/runs": {
"get": {
"operationId": "getEvalRuns",
@@ -3521,6 +3654,116 @@
}
}
},
"/api/v1/presets": {
"get": {
"operationId": "getPresets",
"summary": "The models that come with llmgw: whether each fits this cluster, is in the catalog, was declined",
"tags": [
"presets"
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PresetsView"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/presets/auto": {
"put": {
"operationId": "putPresetsAuto",
"summary": "Turn on or off the adding of presets when a node fits them",
"tags": [
"presets"
],
"parameters": [
{
"$ref": "#/components/parameters/Actor"
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PresetsAutoRequest"
}
}
}
},
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/PresetsView"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/presets/{name}": {
"post": {
"operationId": "postPresetsName",
"summary": "Add a preset to the catalog, and clear its decline",
"tags": [
"presets"
],
"parameters": [
{
"name": "name",
"in": "path",
"required": true,
"description": "The preset's name, as `llmgw ctl model presets` lists it.",
"schema": {
"type": "string"
}
},
{
"$ref": "#/components/parameters/Actor"
}
],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ModelAdded"
}
}
}
},
"401": {
"$ref": "#/components/responses/Unauthorized"
},
"default": {
"$ref": "#/components/responses/Error"
}
}
}
},
"/api/v1/projects": {
"post": {
"operationId": "postProjects",
@@ -5073,7 +5316,7 @@
}
},
"Unauthorized": {
"description": "The admin token is missing or wrong.",
"description": "The token is missing or wrong.",
"content": {
"application/json": {
"schema": {
@@ -6016,6 +6259,52 @@
}
}
},
"JobView": {
"type": "object",
"description": "JobView is one job.",
"properties": {
"id": {
"type": "string"
},
"owner": {
"type": "string"
},
"key_id": {
"type": "string"
},
"endpoint": {
"type": "string"
},
"model": {
"type": "string"
},
"status": {
"type": "string"
},
"progress": {
"description": "Any JSON value."
},
"output_file_id": {
"type": "string"
},
"content_type": {
"type": "string"
},
"error": {
"type": "string"
},
"created_at": {
"type": "string",
"format": "date-time"
},
"started_at": {
"type": "string"
},
"finished_at": {
"type": "string"
}
}
},
"JudgeAskRequest": {
"type": "object",
"description": "JudgeAskRequest is a text to have a judge read.",
@@ -7305,6 +7594,85 @@
}
}
},
"PresetView": {
"type": "object",
"description": "PresetView is one preset as it stands in this cluster.",
"properties": {
"name": {
"type": "string"
},
"makes": {
"type": "string"
},
"description": {
"type": "string"
},
"card": {
"type": "string"
},
"licence": {
"$ref": "#/components/schemas/catalog.License"
},
"fits": {
"type": "boolean",
"description": "Fits is whether some node has a card that meets the preset's need; FitsClasses names those classes."
},
"fits_classes": {
"type": "array",
"items": {
"type": "string"
}
},
"in_catalog": {
"type": "boolean"
},
"added_by": {
"type": "string",
"description": "AddedBy is \"auto\" when the leader added it, who did otherwise, \"\" when it was not added as a preset; AddedAt is when."
},
"added_at": {
"type": "string",
"format": "date-time"
},
"declined": {
"type": "boolean",
"description": "Declined is whether somebody removed it after it was added, so it is not added again by itself; adding it again clears that."
},
"blocked": {
"type": "string",
"description": "Blocked is why the leader does not add it by itself — presets.auto off, no ComfyUI, no card that fits, declined, another model already making the same — empty when it would or has."
}
}
},
"PresetsAutoRequest": {
"type": "object",
"description": "PresetsAutoRequest turns the automatic adding of presets on or off.",
"properties": {
"on": {
"type": "boolean"
}
}
},
"PresetsView": {
"type": "object",
"description": "PresetsView is the models that come with llmgw, and whether the leader adds them by itself.",
"properties": {
"auto": {
"type": "boolean",
"description": "Auto is whether a preset is added when a node fits it and the catalog has no model that makes what it makes (presets.auto)."
},
"comfyui": {
"type": "string",
"description": "ComfyUI is engines.comfyui as it stands: the version the nodes install, \"\" for none — in which case adding a preset sets it, and the control planes start again on that."
},
"presets": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PresetView"
}
}
}
},
"ProjectView": {
"type": "object",
"description": "ProjectView is a project, with how many people and keys are in it.",
@@ -7595,6 +7963,14 @@
"type": "integer",
"format": "int64"
},
"images": {
"type": "integer",
"format": "int64"
},
"meshes": {
"type": "integer",
"format": "int64"
},
"cost_micros": {
"type": "integer",
"format": "int64"
@@ -8569,6 +8945,78 @@
"images": {
"type": "boolean",
"description": "Images is a model that makes pictures, asked for at /v1/images/generations. Implied by an external model whose API is ComfyUI's."
},
"meshes": {
"type": "boolean",
"description": "Meshes is a model that makes 3D models from a picture, asked for as a job at /v1/3d/generations. Implied by a ComfyUI model whose output is meshes."
}
}
},
"catalog.ComfyUI": {
"type": "object",
"description": "ComfyUI is a ComfyUI model's workflow and checkpoint.",
"properties": {
"workflow": {
"type": "string",
"description": "Workflow is a built-in's name (flux-schnell, the default, or flux-dev) or a JSON file on the control plane, in ComfyUI's API format."
},
"checkpoint": {
"type": "string",
"description": "Checkpoint is the file the workflow's {{checkpoint}} names: the source's own name unless set, which a source that is a set of files must."
},
"output": {
"type": "string",
"description": "Output is what the workflow makes: pictures (the default), asked for at /v1/images/generations; meshes, asked for as a job at /v1/3d/generations from a picture; or video, audio or files. Every kind is asked for as a job at /v1/workflows/runs too."
},
"inputs": {
"type": "array",
"items": {
"$ref": "#/components/schemas/catalog.Input"
},
"description": "Inputs are what the workflow takes from a request at /v1/workflows/runs, each the placeholder of its name."
},
"nodes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/catalog.CustomNode"
},
"description": "Nodes are custom nodes the workflow needs, put in each replica's own custom_nodes at the commit named."
},
"huggingface_repo": {
"type": "string",
"description": "HuggingFaceRepo is the repository a custom node loads the weights by name from (Stable Fast 3D's loader asks for stabilityai/stable-fast-3d): the replica's weights are shown to the node there, in the Hugging Face cache of the engines' user, so nothing is fetched and no token is needed when it runs."
},
"environment": {
"type": "string",
"description": "Environment names the models that may share one ComfyUI replica: every model of an environment agrees on the custom nodes they both name, so one replica can carry all of them and ComfyUI moves between their weights itself. Empty is an environment of the model alone."
}
}
},
"catalog.CustomNode": {
"type": "object",
"description": "CustomNode is a ComfyUI node from a git repository.",
"properties": {
"repo": {
"type": "string",
"description": "Repo is the repository, https only."
},
"commit": {
"type": "string",
"description": "Commit is the full commit the node is used at: a branch moves, and a node that changed under a model is a model that changed unasked."
},
"requirements": {
"type": "array",
"items": {
"type": "string"
},
"description": "Requirements are the PyPI packages the node needs, by name and version range only. They are installed beside the replica, never into ComfyUI's environment: a package named here at the range named, and every other one at the version ComfyUI already runs. One named here is first on ComfyUI's path, so its range must suit ComfyUI too."
},
"build": {
"type": "array",
"items": {
"type": "string"
},
"description": "Build are directories of the repository that are Python packages to be compiled rather than downloaded: C++ and CUDA extensions no index has (Stable Fast 3D's texture_baker and uv_unwrapper). Each is built as a wheel with ComfyUI's Python for the card the replica runs on, kept on the node for the next replica on the same kind of card, and installed beside the replica like its requirements."
}
}
},
@@ -8617,6 +9065,24 @@
"workflow": {
"type": "string",
"description": "Workflow is the ComfyUI workflow, in its API format: a built-in's name (flux-schnell, the default, or flux-dev) or a JSON file on the control plane."
},
"output": {
"type": "string",
"description": "Output is what it makes: pictures, or meshes (see ComfyUI.Output)."
}
}
},
"catalog.GPUNeed": {
"type": "object",
"description": "GPUNeed is what card a profile needs.",
"properties": {
"min_compute_capability": {
"type": "number",
"description": "MinComputeCapability is the oldest architecture that runs it: 7.5 is Turing, 8.0 Ampere (bf16, flash-attention 2), 8.9 Ada (fp8), 9.0 Hopper."
},
"min_memory_gb": {
"type": "integer",
"description": "MinMemoryGB is the card's own memory it needs."
}
}
},
@@ -8637,6 +9103,88 @@
}
}
},
"catalog.Input": {
"type": "object",
"description": "Input is one thing a workflow takes from a request: the placeholder {{name}} in its graph, filled with the request's inputs.name.",
"properties": {
"name": {
"type": "string",
"description": "Name is the placeholder's, and the request's key."
},
"type": {
"type": "string",
"description": "Type is one of text, int, float, bool, choice, image, mask, audio, video, file. The last five are files, sent as base64, a data URL, or the id of a file uploaded to /v1/files, and put in ComfyUI's input directory; the placeholder gets the name they were given there."
},
"required": {
"type": "boolean",
"description": "Required inputs must be in every request; the others take Default, or the workflow's own {{name|default}}."
},
"default": {},
"min": {
"type": "number",
"description": "Min and Max bound a number; for text, Max bounds its length."
},
"max": {
"type": "number"
},
"choices": {
"type": "array",
"items": {
"type": "string"
},
"description": "Choices are what a choice may be."
},
"max_mb": {
"type": "integer",
"description": "MaxMB bounds a file input; zero is the type's own bound."
},
"doc": {
"type": "string",
"description": "Doc says what the input is for, to whoever fills it in."
}
}
},
"catalog.License": {
"type": "object",
"description": "License is the terms what a model makes comes under: said beside every result, and written into a mesh, which travels without the API.",
"properties": {
"id": {
"type": "string",
"description": "ID names the licence for a machine: an SPDX identifier where there is one (MIT, Apache-2.0, CC-BY-NC-4.0), else a lowercase name of the licence's own (stability-community, tencent-hunyuan-community). What a person's or a site's choice of licences matches on. Name is for people."
},
"name": {
"type": "string",
"description": "Name is the licence's own: MIT, Apache-2.0."
},
"url": {
"type": "string"
},
"notice": {
"type": "string",
"description": "Notice is what must accompany what was made: attribution, terms on the output, what the weights were trained on."
},
"commercial": {
"type": "boolean",
"description": "Commercial says whether what is made may be used commercially: nil is not said, which is not the same as yes."
},
"excluded_regions": {
"type": "array",
"items": {
"type": "string"
},
"description": "ExcludedRegions are where the licence grants nothing: ISO 3166 codes, and EU for the union (Hunyuan's: EU, UK, KR)."
},
"revenue_cap_usd": {
"type": "integer",
"format": "int64",
"description": "RevenueCapUSD is the yearly revenue above which the licence asks for another agreement (Stability's community licence: 1000000). Zero is none."
},
"attribution": {
"type": "string",
"description": "Attribution is what a product using the output must show: \"Powered by Stability AI\"."
}
}
},
"catalog.Model": {
"type": "object",
"description": "Model is a catalog entry as clients see it.",
@@ -8679,6 +9227,14 @@
"$ref": "#/components/schemas/catalog.External",
"description": "External is a model served by someone else — an OpenAI-compatible API outside the cluster — rather than by replicas placed here. Such a model has no profiles and nothing is placed for it; the router sends its requests to the provider. Most useful named in another model's fallbacks: used only when the cluster has no capacity left. Its prompts leave the cluster, so which keys may name it is worth an allowlist."
},
"comfyui": {
"$ref": "#/components/schemas/catalog.ComfyUI",
"description": "ComfyUI is how a model whose profiles run engine comfyui makes its pictures: the workflow, and the file it loads."
},
"license": {
"$ref": "#/components/schemas/catalog.License",
"description": "License is the terms of what the model makes, for a model whose output somebody ships: shown with every result and written into a mesh."
},
"adapters": {
"type": "array",
"items": {
@@ -8736,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."
}
}
},
@@ -8815,6 +9375,10 @@
"gpu_class": {
"type": "string"
},
"source": {
"type": "string",
"description": "Source is the weights this profile loads, when not the model's: an embedding model served by vLLM from its repository on a card and by llama.cpp from a GGUF on CPUs is one model with two sources. Empty loads the model's. Updates and canaries move the model's source; a profile with its own keeps it."
},
"tp": {
"type": "integer"
},
@@ -8884,6 +9448,32 @@
"kv_events": {
"type": "boolean",
"description": "KVEvents has vLLM publish what its prefix cache holds, and the router route this model's requests by it: to the replica holding the most of a prompt's leading blocks (plan §7.5 tier 2). vLLM only.\n\nOff by default, because it costs something on every request: the router asks a replica to tokenize the prompt before placing it, a round trip and the chat template run twice. It pays where prompts share long prefixes that the approximate tree guesses wrong — many replicas, conversations that move between them, evictions. The engine is started with sha256_cbor block hashing and a fixed PYTHONHASHSEED, which the router's hashing assumes; arguments overriding either leave the index empty, and the agent says so in its log."
},
"gpu": {
"$ref": "#/components/schemas/catalog.GPUNeed",
"description": "GPU says what card the profile needs, in place of naming its class: it stands for every class in the cluster that meets it. A catalog entry written once then places sensibly on any site's cards."
},
"comfyui": {
"$ref": "#/components/schemas/catalog.ProfileComfyUI",
"description": "ComfyUI is this profile's own workflow, checkpoint and nodes, over the model's: an fp8 workflow where the card has fp8, fp16 where it has not."
}
}
},
"catalog.ProfileComfyUI": {
"type": "object",
"description": "ProfileComfyUI is what a ComfyUI profile runs differently from the model: each field set replaces the model's.",
"properties": {
"workflow": {
"type": "string"
},
"checkpoint": {
"type": "string"
},
"nodes": {
"type": "array",
"items": {
"$ref": "#/components/schemas/catalog.CustomNode"
}
}
}
},
@@ -8959,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.",
@@ -9827,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."
@@ -10266,6 +10905,14 @@
"CostMicros": {
"type": "integer",
"format": "int64"
},
"Images": {
"type": "integer",
"format": "int64"
},
"Meshes": {
"type": "integer",
"format": "int64"
}
}
},
File diff suppressed because it is too large. Load diff
+69 -2
View File
@@ -38,6 +38,14 @@ export ANTHROPIC_BASE_URL=https://llm.example.com
export ANTHROPIC_API_KEY=$LLMGW_API_KEY
```
### The API described
Every endpoint is described in OpenAPI 3.1 at `/v1/openapi.json`. It needs
no key, so a client generator can be pointed at it. `/docs/api` shows it in
Swagger UI, where *Authorize* takes a key and *Try it out* sends a request to
the gateway. People with a part in running the gateway also find the admin
API's document there.
## A first request
### curl
@@ -526,6 +534,64 @@ 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
it allows commercial use and the regions it grants nothing in). On your
profile page, under **Licences**, you can leave licences out: one by one,
every licence that forbids commercial use, or every licence that grants
nothing in a region you name (`EU` for the union). The rules cover models
added later too. From then on those models are not listed to your keys and
a request for one is refused with 403 and the code `licence_excluded`,
naming the choice that left it out — on every key you own and on the
portal's pages, whatever a key's own allowlist says. A change reaches keys
already in use within a minute.
### Headers llmgw sends back
| Header | Says |
@@ -545,9 +611,10 @@ its usual exception with the gateway's message in it. The ones worth knowing:
|---|---|
| 400 | Besides a malformed request: a prompt too long for the model, or a guardrail policy refused it (the code `content_policy_violation`). |
| 401 | No key, a mistyped one, or one that was revoked or has expired. |
| 403 | The key may not use that model. |
| 403 | The key may not use that model; with the code `licence_excluded`, its licence is one you left out on your profile page. |
| 429 | A rate limit or budget of the key, its project or its team was reached. `Retry-After` says when to try again, and is absent when waiting will not help — a spent monthly budget. |
| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the request waited as long as it may. The SDKs retry by themselves; a name that is not served will not start to be. |
| 503 | No model of that name is served (the message lists the ones that are), the model is still loading, or every replica is busy and the queue is full. The SDKs retry by themselves; a name that is not served will not start to be. |
| 504 | Every replica was busy for as long as the key's band may wait. `Retry-After` says when to send it again. |
## Priority
+366 -18
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.
@@ -1038,6 +1070,27 @@ qwen2.5-coder-3b-instruct yes 1/1 ready coder
No restart, and it survives one: models added this way are stored, while
anything in `server.yaml` is declarative and returns on every start.
A profile may load weights of its own. An embedding model, for example, can
be served by vLLM from its repository on a card and by llama.cpp from a GGUF
on CPUs, as one model:
```yaml
name: qwen3-embed-4b
source: Qwen/Qwen3-Embedding-4B # what a profile loads unless it says
capabilities: { embeddings: true }
profiles:
- name: gpu
engine: vllm
gpu_class: 6000-24g
args: ["--runner", "pooling"]
- name: cpu
engine: llamacpp
source: hf:Qwen/Qwen3-Embedding-4B-GGUF/Qwen3-Embedding-4B-Q8_0.gguf
```
Updates and canaries move the model's `source`; a profile with its own keeps
it.
### Models that load when someone asks
A cluster with more models than GPUs can hold every model in its catalog and
@@ -1272,9 +1325,108 @@ by name. The cluster page and `ctl models` mark it `external`.
### Pictures from ComfyUI
A ComfyUI server is an external model with `api: comfyui`. Clients ask it for
pictures at OpenAI's `/v1/images/generations`; llmgw fills a ComfyUI workflow
with the request, queues it, waits, and sends the pictures back as `b64_json`:
Clients ask for pictures at OpenAI's `/v1/images/generations`. llmgw has them
made by ComfyUI: it fills a ComfyUI workflow with the request, queues it,
waits, and sends the pictures back as `b64_json`.
**On the cluster's own cards.** Use engine `comfyui`. The model is placed on a
GPU node like any other, started when asked for, and stopped when unused.
**The short way: a preset.** Some models come with llmgw, with their
workflow, custom nodes, packages and licence already found and tried:
```
llmgw ctl model presets
llmgw ctl model add flux-schnell # pictures, Apache 2.0, a card of 24 GB, Volta or newer
llmgw ctl model add triposr # 3D models from a picture, MIT, a card of 8 GB
```
A preset's profile says the card it needs, not a class, so it places on
whatever cards the site has. When the cluster's settings name no ComfyUI,
adding one sets `engines.comfyui` to the version this release installs, and
every node with an NVIDIA GPU installs it; a cluster that names a version,
or `""` for none, keeps it; the control planes then start again on that
setting, standbys first.
They also come by themselves. Once a node has a card that fits one, and the
catalog has nothing that makes what it makes, the leader adds it, on demand —
where the cluster already installs ComfyUI; it never sets `engines.comfyui`
by itself. A preset somebody removes stays out until it is added again.
`llmgw ctl model presets auto off` (the setting `presets.auto`) stops it.
The cluster page on the portal has a **Pictures and 3D models** card under
the models: each preset, the classes of card here it fits, whether it is in
the catalog and who added it, why the leader has not, an **Add** button (one
that would also install ComfyUI says so first) and the switch for adding
them by themselves. In the console, the models screen shows the same: **A**
adds one, **P** turns adding them by themselves on or off.
`llmgw ctl model presets` prints it. The rest of this section is for a model
of your own.
The node must offer the engine. Ask for it in the cluster's settings, and every
node with an NVIDIA GPU installs it:
```
llmgw ctl config set engines.comfyui 0.39.2
```
The agent fetches ComfyUI's source from GitHub and checks it against the
digest llmgw holds for that version. GitHub publishes none for a tag's archive,
so a version llmgw does not know is refused. The agent then builds a Python
environment beside the source, under `/opt/llmgw/engines/comfyui/`. Its
dependencies are resolved as PyPI stood the day after the release. torch comes
from PyPI (CUDA 13), from PyTorch's CUDA 12.9 build where the driver is older
than 580, or from its CUDA 12.6 build on cards older than Turing (a V100):
torch's CUDA 12.8 and newer builds have no kernels for them. The install is
checked on the node's GPU, and takes about 10 GB.
`engine_overrides` can keep it off nodes that should not have it.
Alternatively, install ComfyUI by hand. You need a directory with `main.py` and
its environment in `.venv`, readable by the user engines run as
(`llmgw-engine`). Name it in that node's `agent.yaml`; a node with one named
there is not given the installed one:
```yaml
engines:
comfyui:
dir: /opt/comfyui # python: defaults to /opt/comfyui/.venv/bin/python
```
Then add the model:
```yaml
- name: flux-schnell
source: hf:Comfy-Org/flux1-schnell/flux1-schnell-fp8.safetensors
comfyui:
workflow: flux-schnell # or flux-dev, or a JSON file
scaling: {min: 0, max: 1, idle_timeout: 15m}
profiles:
- name: gpu
engine: comfyui
gpu_class: 6000-24g
tp: 1
```
The source is fetched as for any model. It can be one file, such as the
single-file checkpoints the built-in workflows load. It can also be a mirror
set (a URL ending in `/`) for a model in several files, such as a diffusion
model, its text encoders and its VAE. ComfyUI sees every file of a set by its
own name, so a workflow of your own can load each one. A set must name the
file that `{{checkpoint}}` stands for in `comfyui.checkpoint`. A single file
is named by its own file name.
ComfyUI asks nobody for a key. The agent therefore starts it behind a gate
(`llmgw comfyui-gate`). ComfyUI listens on the node's loopback only. The gate
listens on the replica's port, demands the replica's key, and lets through only
what the router asks: queueing, waiting, fetching a picture and cancelling.
Uploads and node installs are refused. Each replica has its own directory,
`~llmgw-engine/comfyui/REPLICA`, and ComfyUI's output can be read with `llmgw
ctl logs NODE --source SHARD`. A profile's `args` go to `main.py`.
`--disable-smart-memory` frees the card between pictures, at the cost of loading
the model again for each one.
**On a ComfyUI server outside the cluster.** Make it an external model with
`api: comfyui`:
```yaml
- name: flux-schnell
@@ -1286,6 +1438,8 @@ with the request, queues it, waits, and sends the pictures back as `b64_json`:
workflow: flux-schnell # or flux-dev, or a JSON file
```
Like any external model, its requests count as leaving the cluster.
`flux-schnell` (the default) and `flux-dev` are built in. They suit the
single-file FLUX checkpoints that ComfyUI loads with `CheckpointLoaderSimple`.
For any other model, export the workflow from ComfyUI with **Save (API)** and
@@ -1308,12 +1462,21 @@ before the picture is sent.
Requests for pictures pass the same checks as any other request: keys,
allowlists, limits, admission, and the guardrails on the prompt. A client that
leaves takes its workflow off the ComfyUI queue. Usage is recorded, but for
now it is charged as the prompt's tokens; a per-picture price is not yet
available.
leaves takes its workflow off the ComfyUI queue.
Start ComfyUI with `--disable-smart-memory` on a card that llmgw also places
models on. Without it, ComfyUI keeps the model on the card after each picture.
Each request's usage row records how many pictures it made (`llmgw ctl request
ID` shows them). A picture's price is set with `per_image`, in micro-units like
the other prices, and is charged on top of the prompt's tokens:
```yaml
auth:
pricing:
flux-schnell: {prompt_per_1k: 0, per_image: 20000}
```
An external ComfyUI on a card that llmgw also places models on should be
started with `--disable-smart-memory`. Without it, ComfyUI keeps the model on
the card after each picture, and llmgw sees that memory as somebody else's.
### Federating clusters
@@ -1595,6 +1758,7 @@ and accounting exactly as an API call does.
| `POST /v1/audio/transcriptions`, `/v1/audio/translations` | the multipart form passed through as it came, to an engine that serves speech to text: vLLM with Whisper |
| `POST /v1/moderations` | answered by the guardrails' judge model (`role: guard`): `flagged`, and the judge's own categories |
| `/v1/files`, `/v1/batches` | batches; see [Batches](#batches) |
| `/v1/3d/generations` | 3D models, as jobs asked after; see [Jobs](#jobs-3d-models) |
| `GET /v1/models` | the models this key may use |
## Watching it
@@ -1695,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
@@ -1953,6 +2134,142 @@ Each line of the file is `{"custom_id": …, "method": "POST", "url":
or past their retention — a project under a guardrails policy with
`no_store` is refused it.
## Jobs: 3D models
A 3D model from a picture takes minutes on a card that first has to be
emptied of whatever else was on it. No client should hold a connection for
that, so it is a job: queued, answered with an id, asked after, its result
downloaded.
```
curl http://gateway:8080/v1/3d/generations -H "Authorization: Bearer $LLMGW_API_KEY" \
-d '{"model": "trellis2", "image": "<base64>"}'
# → 202 {"id": "job_…", "status": "queued", …}
curl http://gateway:8080/v1/3d/generations/job_… -H "Authorization: Bearer $LLMGW_API_KEY"
# → {"status": "completed", "output_file_id": "file-…", "content_type": "model/gltf-binary", "usage": {…}}
curl http://gateway:8080/v1/files/file-…/content -H "Authorization: Bearer $LLMGW_API_KEY" -o model.glb
curl -X POST http://gateway:8080/v1/3d/generations/job_…/cancel -H "Authorization: Bearer $LLMGW_API_KEY"
```
- **Status:** `queued`, `running`, `completed`, `failed` (with `error`),
`cancelling`, `cancelled`, `expired` (not finished within 24 h).
`progress` carries what the engine said of the work so far, once it says
anything; `GET /v1/3d/generations` lists the caller's jobs. While the
model waits for another on-demand model to give up the card,
`progress` is `{"state":"waiting_gpu","held_by":"flux-schnell","eta_seconds":N}`:
N is when the holder is stopped (idle and old enough to evict, or at its
idle timeout) plus how long the model's last start took, and is left out
when that is not known. A request made directly gets the same as a 503
with `X-Llmgw-Waiting-For`, `X-Llmgw-Eta-Seconds` and `Retry-After`.
- **Refused at the door:** no key, a model that is not there or not in the
key's allowlist, a request the model cannot serve. Refused when served,
and said as the job's error: a spent budget, the guardrails, a model
that will not come up.
- **How it is served:** the request is kept as it was sent and replayed by
whichever gateway holds the job's lease, through the same path a
client's request takes — limits, admission in the key's band, guardrails,
accounting — by the leading control plane, since a standby has nothing
to send it to. A model said to fit nowhere is asked for again for five
minutes before that is the job's error: an agent re-registering says so
for a few seconds. It is one usage record under the job's id, tagged
`job=<id>`, charged like any request; `GET` shows the usage once it is
done. A model still loading, a rate limit or a full queue is waited out
for as long as the job has: nobody is holding a connection, which is the
point.
- **Surviving a restart:** a gateway that stops hands its jobs to another
within two minutes; a job it was running is run again from the start.
- **What it keeps:** the request as sent, and the result as a file owned
by the key's project, kept `router.batch.retain_days` (30) like a batch's
files; the job's record goes with it. A project under a `no_store`
policy is refused jobs, as it is batches.
A request may also send `seed` and `resolution` (the model's own grid, a
multiple of 32 from 64 to 2048). The picture should show one object on a
plain background: the model makes a mesh of what it sees, background
included.
### A model that makes them
A 3D model is made by a ComfyUI model whose `comfyui.output` is `meshes`
(see [Pictures from ComfyUI](#pictures-from-comfyui) for engine `comfyui`).
Its workflow is a JSON file in ComfyUI's API format: `{{image}}` is the
uploaded picture, which a `LoadImage` node reads; `{{seed}}`,
`{{resolution|256}}` and `{{checkpoint}}` are as for pictures. The node that
saves the mesh may save a GLB or an OBJ; an OBJ leaves the gateway as a GLB,
vertex colours kept.
TripoSR (MIT), on any card of 8 GB, comes with llmgw: `llmgw ctl model add
triposr`. What that adds is this entry, and its workflow is built in:
```yaml
- name: triposr
source: hf:stabilityai/TripoSR/model.ckpt
comfyui:
workflow: triposr # built in; a model of your own names a JSON file on every control plane
output: meshes
nodes:
- repo: https://github.com/flowtyone/ComfyUI-Flowty-TripoSR
commit: a0c94ac60a7cc062604f61aeeea6d0d493521de3
requirements: [omegaconf, trimesh, scikit-image, imageio,
"transformers>=4.50.3,<5", "huggingface-hub<1", tokenizers,
rembg, onnxruntime]
license:
id: MIT
name: MIT
commercial: true
url: https://huggingface.co/stabilityai/TripoSR/blob/main/LICENSE
notice: "Made with TripoSR (Stability AI and Tripo AI, MIT). ..."
scaling: {min: 0, max: 1, idle_timeout: 10m}
profiles:
- {name: gpu, engine: comfyui, tp: 1, gpu: {min_compute_capability: 7.0, min_memory_gb: 8}}
```
```json
{
"1": {"class_type": "LoadImage", "inputs": {"image": "{{image}}"}},
"5": {"class_type": "LLMGWPrepareForMesh", "inputs": {"image": ["1", 0], "mask": ["1", 1],
"foreground_ratio": 0.85, "background": 0.5, "remove_background": "auto", "rembg_model": "u2net"}},
"2": {"class_type": "TripoSRModelLoader", "inputs": {"model": "{{checkpoint}}", "chunk_size": 8192}},
"3": {"class_type": "TripoSRSampler", "inputs": {"model": ["2", 0], "reference_image": ["5", 0],
"geometry_resolution": "{{resolution|256}}", "threshold": 25.0}},
"4": {"class_type": "TripoSRViewer", "inputs": {"mesh": ["3", 0]}}
}
```
`LLMGWPrepareForMesh` comes with the gate: it cuts the object out — by the
picture's alpha, or by rembg where it has none — and centres it on a plain
background, without which the background becomes geometry.
- **Custom nodes** are named by https repository and full commit (a branch
is refused: it moves). The gate puts each in the replica's own
`custom_nodes` and installs its `requirements` beside the replica, never
into ComfyUI's environment. A package named there is installed at the
range named; every other one at the version ComfyUI already runs. A
package named is first on ComfyUI's path, so its range must suit ComfyUI
as well. TripoSR's checkpoint loads with transformers 4, which ComfyUI's
`>=4.50.3` allows.
- **Progress:** the gate listens to ComfyUI's websocket and the router
writes what it hears into the job: `{"state": "queued", "ahead": 2}`,
then `{"state": "running", "node_class": "KSampler", "step": 12,
"steps": 30, "percent": 40}` for a node that counts its steps, and
`done` at the end.
- **Licence:** a model's `license` is shown in `/v1/models`, in the job and
on the portal, and written into the GLB's `asset.extras.license`, since
the file travels without the API. Choose the model for its licence before
its quality when what it makes is shipped. TRELLIS.2 needs Meta's gated
DINOv3 and, as published, a background remover under a non-commercial
licence; Hunyuan3D's licence excludes the EU, the UK and South Korea.
- **Price:** `per_mesh` in `auth.pricing`, beside `per_image`; each usage
row says how many meshes it made (`meshes`).
- **Cards:** the strongest models today (TRELLIS.2, Direct3D-S2) need
flash-attention, which runs on Ampere and newer. A Turing card such as
the RTX 6000 runs TripoSR.
People make them at `/3d` on the portal: they choose a picture and watch
the job, and the mesh is shown in a viewer (model-viewer, served from the
gateway) with a download link and the licence. `llmgw ctl 3d list`, `get`
and `cancel` show and stop every owner's jobs.
## Conversations across models
Off everywhere until a project asks. llmgw otherwise stores no message content
@@ -2034,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
@@ -2050,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
+18
View File
@@ -146,6 +146,24 @@ Rules that apply:
- Every document added or removed is in the audit log (`llmgw ctl audit`),
by name. The text is never recorded.
### A large import
A few thousand documents ask the embedding model for far more than it can
answer at once. Give the key that sends them a band of its own, below the
people's, that may wait for room for minutes rather than the 30 seconds the
others wait:
```sh
llmgw ctl config set router.admission.bands '[{name: gold, priority: 300, saturation_ceiling: 0.85},
{name: bronze, priority: 100, saturation_ceiling: 0.3}, {name: bulk, priority: 50, saturation_ceiling: 0.3, queue_ttl: 5m}]'
llmgw keys create import --project Acme/docs --band bulk
```
The people's questions then go first, and the import waits behind them. A
request that waits as long as its band allows is answered 504 with
`Retry-After`, and `llmgw-kb` sends it again rather than counting the
passage as failed. It gives up after 15 minutes.
## For everyone: `llmgw-kb`
### Get it
+473 -1
View File
@@ -61,6 +61,16 @@ rather than repeated.
| `vision` | `bool` | [Capabilities](#capabilities) |
| `embeddings` | `bool` | [Capabilities](#capabilities) |
| `images` | `bool` | [Capabilities](#capabilities) |
| `meshes` | `bool` | [Capabilities](#capabilities) |
| `workflow` | `string` | [ComfyUI](#comfyui) |
| `checkpoint` | `string` | [ComfyUI](#comfyui) |
| `output` | `string` | [ComfyUI](#comfyui) |
| `inputs` | `[]Input` | [ComfyUI](#comfyui) |
| `nodes` | `[]CustomNode` | [ComfyUI](#comfyui) |
| `huggingface_repo` | `string` | [ComfyUI](#comfyui) |
| `environment` | `string` | [ComfyUI](#comfyui) |
| `dir` | `string` | [ComfyUIConfig](#comfyuiconfig) |
| `python` | `string` | [ComfyUIConfig](#comfyuiconfig) |
| `interval` | `time.Duration` | [Control](#control) |
| `base_backoff` | `time.Duration` | [Control](#control) |
| `max_backoff` | `time.Duration` | [Control](#control) |
@@ -72,6 +82,10 @@ rather than repeated.
| `eval_dir` | `string` | [Control](#control) |
| `enabled` | `bool` | [Control](#control) |
| `lease_ttl` | `time.Duration` | [Control](#control) |
| `repo` | `string` | [CustomNode](#customnode) |
| `commit` | `string` | [CustomNode](#customnode) |
| `requirements` | `[]string` | [CustomNode](#customnode) |
| `build` | `[]string` | [CustomNode](#customnode) |
| `id` | `string` | [Endpoint](#endpoint) |
| `url` | `string` | [Endpoint](#endpoint) |
| `api_key` | `string` | [Endpoint](#endpoint) |
@@ -94,6 +108,7 @@ rather than repeated.
| `peer` | `string` | [External](#external) |
| `api` | `string` | [External](#external) |
| `workflow` | `string` | [External](#external) |
| `output` | `string` | [External](#external) |
| `budgets` | `[]FacilityBudget` | [Facility](#facility) |
| `margin` | `float64` | [Facility](#facility) |
| `gpu_watts` | `float64` | [Facility](#facility) |
@@ -106,8 +121,19 @@ rather than repeated.
| `name` | `string` | [Federation](#federation) |
| `interval` | `time.Duration` | [Federation](#federation) |
| `enabled` | `bool` | [FirewallSettings](#firewallsettings) |
| `min_compute_capability` | `float64` | [GPUNeed](#gpuneed) |
| `min_memory_gb` | `int` | [GPUNeed](#gpuneed) |
| `format` | `string` | [GuardModel](#guardmodel) |
| `categories` | `[]string` | [GuardModel](#guardmodel) |
| `name` | `string` | [Input](#input) |
| `type` | `string` | [Input](#input) |
| `required` | `bool` | [Input](#input) |
| `default` | `any` | [Input](#input) |
| `min` | `*float64` | [Input](#input) |
| `max` | `*float64` | [Input](#input) |
| `choices` | `[]string` | [Input](#input) |
| `max_mb` | `int` | [Input](#input) |
| `doc` | `string` | [Input](#input) |
| `model` | `string` | [JudgePolicy](#judgepolicy) |
| `models` | `[]string` | [JudgePolicy](#judgepolicy) |
| `input` | `string` | [JudgePolicy](#judgepolicy) |
@@ -122,6 +148,14 @@ rather than repeated.
| `notice` | `string` | [Landing](#landing) |
| `language` | `string` | [Landing](#landing) |
| `docs_sign_in` | `bool` | [Landing](#landing) |
| `id` | `string` | [License](#license) |
| `name` | `string` | [License](#license) |
| `url` | `string` | [License](#license) |
| `notice` | `string` | [License](#license) |
| `commercial` | `*bool` | [License](#license) |
| `excluded_regions` | `[]string` | [License](#license) |
| `revenue_cap_usd` | `int64` | [License](#license) |
| `attribution` | `string` | [License](#license) |
| `bin` | `string` | [LlamaCppConfig](#llamacppconfig) |
| `parallel` | `int` | [LlamaCppConfig](#llamacppconfig) |
| `threads` | `int` | [LlamaCppConfig](#llamacppconfig) |
@@ -139,6 +173,8 @@ rather than repeated.
| `class` | `string` | [Model](#model) |
| `owner` | `string` | [Model](#model) |
| `external` | `*External` | [Model](#model) |
| `comfyui` | `*ComfyUI` | [Model](#model) |
| `license` | `*License` | [Model](#model) |
| `adapters` | `[]Adapter` | [Model](#model) |
| `max_loaded_adapters` | `int` | [Model](#model) |
| `fallbacks` | `[]string` | [Model](#model) |
@@ -169,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) |
@@ -208,6 +246,7 @@ rather than repeated.
| `match_ratio` | `float64` | [Prefix](#prefix) |
| `balance_abs` | `float64` | [Prefix](#prefix) |
| `balance_rel` | `float64` | [Prefix](#prefix) |
| `auto` | `bool` | [PresetSettings](#presetsettings) |
| `source` | `string` | [PreviousOrigin](#previousorigin) |
| `revision` | `string` | [PreviousOrigin](#previousorigin) |
| `files` | `[]OriginFile` | [PreviousOrigin](#previousorigin) |
@@ -215,6 +254,7 @@ rather than repeated.
| `engine` | `string` | [Profile](#profile) |
| `engine_version` | `string` | [Profile](#profile) |
| `gpu_class` | `string` | [Profile](#profile) |
| `source` | `string` | [Profile](#profile) |
| `tp` | `int` | [Profile](#profile) |
| `pp` | `int` | [Profile](#profile) |
| `gpus_per_node` | `int` | [Profile](#profile) |
@@ -231,6 +271,11 @@ rather than repeated.
| `priority` | `int` | [Profile](#profile) |
| `startup_timeout` | `time.Duration` | [Profile](#profile) |
| `kv_events` | `bool` | [Profile](#profile) |
| `gpu` | `*GPUNeed` | [Profile](#profile) |
| `comfyui` | `*ProfileComfyUI` | [Profile](#profile) |
| `workflow` | `string` | [ProfileComfyUI](#profilecomfyui) |
| `checkpoint` | `string` | [ProfileComfyUI](#profilecomfyui) |
| `nodes` | `[]CustomNode` | [ProfileComfyUI](#profilecomfyui) |
| `activation_wait` | `time.Duration` | [Router](#router) |
| `scrape_interval` | `time.Duration` | [Router](#router) |
| `scrape_timeout` | `time.Duration` | [Router](#router) |
@@ -302,6 +347,8 @@ rather than repeated.
| `mail` | `mail.Config` | [Server](#server) |
| `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) |
@@ -347,9 +394,12 @@ rather than repeated.
| `secret_file` | `string` | [Webhook](#webhook) |
| `prompt_per_1k` | `int64` | [accounting.Price](#accountingprice) |
| `completion_per_1k` | `int64` | [accounting.Price](#accountingprice) |
| `per_image` | `int64` | [accounting.Price](#accountingprice) |
| `per_mesh` | `int64` | [accounting.Price](#accountingprice) |
| `name` | `string` | [admission.Band](#admissionband) |
| `priority` | `int` | [admission.Band](#admissionband) |
| `saturation_ceiling` | `float64` | [admission.Band](#admissionband) |
| `queue_ttl` | `time.Duration` | [admission.Band](#admissionband) |
| `bands` | `[]Band` | [admission.Config](#admissionconfig) |
| `default_band` | `string` | [admission.Config](#admissionconfig) |
| `queue_ttl` | `time.Duration` | [admission.Config](#admissionconfig) |
@@ -379,14 +429,18 @@ rather than repeated.
| `name` | `string` | [config.Band](#configband) |
| `priority` | `int` | [config.Band](#configband) |
| `saturation_ceiling` | `float64` | [config.Band](#configband) |
| `queue_ttl` | `time.Duration` | [config.Band](#configband) |
| `prompt_per_1k` | `int64` | [config.Price](#configprice) |
| `completion_per_1k` | `int64` | [config.Price](#configprice) |
| `per_image` | `int64` | [config.Price](#configprice) |
| `per_mesh` | `int64` | [config.Price](#configprice) |
| `disabled` | `[]string` | [engines.Config](#enginesconfig) |
| `vllm` | `VLLMConfig` | [engines.Config](#enginesconfig) |
| `sglang` | `SGLangConfig` | [engines.Config](#enginesconfig) |
| `llamacpp` | `LlamaCppConfig` | [engines.Config](#enginesconfig) |
| `trtllm` | `TRTLLMConfig` | [engines.Config](#enginesconfig) |
| `sim` | `SimConfig` | [engines.Config](#enginesconfig) |
| `comfyui` | `ComfyUIConfig` | [engines.Config](#enginesconfig) |
| `enabled` | `*bool` | [guard.Config](#guardconfig) |
| `default` | `[]string` | [guard.Config](#guardconfig) |
| `policies` | `map[string]Policy` | [guard.Config](#guardconfig) |
@@ -728,6 +782,89 @@ Images is a model that makes pictures, asked for at
/v1/images/generations. Implied by an external model whose API is
ComfyUI's.
### `meshes`
`bool`
Meshes is a model that makes 3D models from a picture, asked for as a
job at /v1/3d/generations. Implied by a ComfyUI model whose output is
meshes.
## ComfyUI
### `workflow`
`string`
Workflow is a built-in's name (flux-schnell, the default, or
flux-dev) or a JSON file on the control plane, in ComfyUI's API
format.
### `checkpoint`
`string`
Checkpoint is the file the workflow's {{checkpoint}} names: the
source's own name unless set, which a source that is a set of files
must.
### `output`
`string`
Output is what the workflow makes: pictures (the default), asked for
at /v1/images/generations; meshes, asked for as a job at
/v1/3d/generations from a picture; or video, audio or files. Every
kind is asked for as a job at /v1/workflows/runs too.
### `inputs`
`[]Input`
Inputs are what the workflow takes from a request at
/v1/workflows/runs, each the placeholder of its name.
### `nodes`
`[]CustomNode`
Nodes are custom nodes the workflow needs, put in each replica's own
custom_nodes at the commit named.
### `huggingface_repo`
`string`
HuggingFaceRepo is the repository a custom node loads the weights by
name from (Stable Fast 3D's loader asks for stabilityai/stable-fast-3d):
the replica's weights are shown to the node there, in the Hugging Face
cache of the engines' user, so nothing is fetched and no token is
needed when it runs.
### `environment`
`string`
Environment names the models that may share one ComfyUI replica: every
model of an environment agrees on the custom nodes they both name, so
one replica can carry all of them and ComfyUI moves between their
weights itself. Empty is an environment of the model alone.
## ComfyUIConfig
### `dir`
`string`
Dir is ComfyUI's directory, where main.py is. Readable by the user
engines run as.
### `python`
`string`
Python is the interpreter of its environment; Dir's .venv when unset.
## Control
### `interval`
@@ -811,7 +948,43 @@ 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
### `repo`
`string`
Repo is the repository, https only.
### `commit`
`string`
Commit is the full commit the node is used at: a branch moves, and a
node that changed under a model is a model that changed unasked.
### `requirements`
`[]string`
Requirements are the PyPI packages the node needs, by name and version
range only. They are installed beside the replica, never into
ComfyUI's environment: a package named here at the range named, and
every other one at the version ComfyUI already runs. One named here
is first on ComfyUI's path, so its range must suit ComfyUI too.
### `build`
`[]string`
Build are directories of the repository that are Python packages to
be compiled rather than downloaded: C++ and CUDA extensions no index
has (Stable Fast 3D's texture_baker and uv_unwrapper). Each is built
as a wheel with ComfyUI's Python for the card the replica runs on,
kept on the node for the next replica on the same kind of card, and
installed beside the replica like its requirements.
## Endpoint
@@ -956,6 +1129,12 @@ Workflow is the ComfyUI workflow, in its API format: a built-in's name
(flux-schnell, the default, or flux-dev) or a JSON file on the control
plane.
### `output`
`string`
Output is what it makes: pictures, or meshes (see ComfyUI.Output).
## Facility
### `budgets`
@@ -1056,6 +1235,21 @@ site whose own firewall does that job; the rules are kept, and true
puts them back. Changed in the cluster's settings, it is in force on
every node and control plane within seconds.
## GPUNeed
### `min_compute_capability`
`float64`
MinComputeCapability is the oldest architecture that runs it: 7.5 is
Turing, 8.0 Ampere (bf16, flash-attention 2), 8.9 Ada (fp8), 9.0 Hopper.
### `min_memory_gb`
`int`
MinMemoryGB is the card's own memory it needs.
## GuardModel
### `format`
@@ -1075,6 +1269,62 @@ ShieldGemma: Yes or No), or json ({"safe": false, "categories":
Categories the model is asked about, for shieldgemma — each a policy
sentence — and the names reported for llamaguard3's S1, S2, ….
## Input
### `name`
`string`
Name is the placeholder's, and the request's key.
### `type`
`string`
Type is one of text, int, float, bool, choice, image, mask, audio,
video, file. The last five are files, sent as base64, a data URL, or
the id of a file uploaded to /v1/files, and put in ComfyUI's input
directory; the placeholder gets the name they were given there.
### `required`
`bool`
Required inputs must be in every request; the others take Default,
or the workflow's own {{name|default}}.
### `default`
`any`
### `min`
`*float64`
Min and Max bound a number; for text, Max bounds its length.
### `max`
`*float64`
### `choices`
`[]string`
Choices are what a choice may be.
### `max_mb`
`int`
MaxMB bounds a file input; zero is the type's own bound.
### `doc`
`string`
Doc says what the input is for, to whoever fills it in.
## JudgePolicy
### `model`
@@ -1186,6 +1436,63 @@ llmgw in general — no address, model or key of this installation —
which the project's repository publishes anyway. On, a visitor is sent
to sign in, and without accounts the portal needs an API key.
## License
### `id`
`string`
ID names the licence for a machine: an SPDX identifier where there is
one (MIT, Apache-2.0, CC-BY-NC-4.0), else a lowercase name of the
licence's own (stability-community, tencent-hunyuan-community). What a
person's or a site's choice of licences matches on. Name is for people.
### `name`
`string`
Name is the licence's own: MIT, Apache-2.0.
### `url`
`string`
### `notice`
`string`
Notice is what must accompany what was made: attribution, terms on
the output, what the weights were trained on.
### `commercial`
`*bool`
Commercial says whether what is made may be used commercially: nil
is not said, which is not the same as yes.
### `excluded_regions`
`[]string`
ExcludedRegions are where the licence grants nothing: ISO 3166 codes,
and EU for the union (Hunyuan's: EU, UK, KR).
### `revenue_cap_usd`
`int64`
RevenueCapUSD is the yearly revenue above which the licence asks for
another agreement (Stability's community licence: 1000000). Zero is
none.
### `attribution`
`string`
Attribution is what a product using the output must show: "Powered by
Stability AI".
## LlamaCppConfig
### `bin`
@@ -1342,6 +1649,25 @@ allowlist.
Takes a [External](#external) block.
### `comfyui`
`*ComfyUI`
ComfyUI is how a model whose profiles run engine comfyui makes its
pictures: the workflow, and the file it loads.
Takes a [ComfyUI](#comfyui) block.
### `license`
`*License`
License is the terms of what the model makes, for a model whose
output somebody ships: shown with every result and written into a
mesh.
Takes a [License](#license) block.
### `adapters`
`[]Adapter`
@@ -1589,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`
@@ -1837,6 +2182,20 @@ before affinity is abandoned.
`float64`
## PresetSettings
### `auto`
`bool`
Auto has the leading control plane add a picture model (flux-schnell)
and a 3D model (triposr) by itself, on demand, once a node has a card
that fits one and the catalog has no model that makes the same kind of
thing — so a new cluster's portal has its pictures and 3D pages
without anybody writing a definition. Only where the cluster already
installs ComfyUI (engines.comfyui). A preset somebody removed is not
added again by itself. In force on the leader within a minute.
## PreviousOrigin
### `source`
@@ -1873,6 +2232,16 @@ version starts with it: "0.11" takes vLLM 0.11.0 and 0.11.1, not
`string`
### `source`
`string`
Source is the weights this profile loads, when not the model's: an
embedding model served by vLLM from its repository on a card and by
llama.cpp from a GGUF on CPUs is one model with two sources. Empty
loads the model's. Updates and canaries move the model's source; a
profile with its own keeps it.
### `tp`
`int`
@@ -2020,6 +2389,39 @@ is started with sha256_cbor block hashing and a fixed PYTHONHASHSEED,
which the router's hashing assumes; arguments overriding either leave
the index empty, and the agent says so in its log.
### `gpu`
`*GPUNeed`
GPU says what card the profile needs, in place of naming its class:
it stands for every class in the cluster that meets it. A catalog
entry written once then places sensibly on any site's cards.
Takes a [GPUNeed](#gpuneed) block.
### `comfyui`
`*ProfileComfyUI`
ComfyUI is this profile's own workflow, checkpoint and nodes, over the
model's: an fp8 workflow where the card has fp8, fp16 where it has not.
Takes a [ProfileComfyUI](#profilecomfyui) block.
## ProfileComfyUI
### `workflow`
`string`
### `checkpoint`
`string`
### `nodes`
`[]CustomNode`
## Router
### `activation_wait`
@@ -2596,6 +2998,23 @@ Firewall turns llmgw's firewall on every machine on or off.
Takes a [FirewallSettings](#firewallsettings) block.
### `presets`
`PresetSettings`
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`
@@ -2936,6 +3355,21 @@ is sha256=<hex HMAC of the body>.
`int64`
### `per_image`
`int64`
PerImage is what one picture costs, for a model that makes them: a
picture's cost is the card's time, which its prompt's few tokens do
not measure.
### `per_mesh`
`int64`
PerMesh is what one 3D model costs: minutes of a card, which no
token count measures.
## admission.Band
### `name`
@@ -2955,6 +3389,15 @@ Priority orders bands; higher is served first.
SaturationCeiling is the pool saturation above which this band waits.
1.0 means "never hold this band back".
### `queue_ttl`
`time.Duration`
QueueTTL, set, is how long this band's requests may wait instead of
the controller's: a band for bulk work — a knowledge-base import, a
backfill — whose requests had rather wait ten minutes behind people
than be turned away after thirty seconds.
## admission.Config
### `bands`
@@ -3168,6 +3611,14 @@ Takes a [Tracing](#tracing) block.
`float64`
### `queue_ttl`
`time.Duration`
QueueTTL is how long this band's requests may wait for room; the
admission's queue_ttl unless set. Long for a band of bulk work, which
had rather wait behind people than be turned away.
## config.Price
### `prompt_per_1k`
@@ -3178,6 +3629,19 @@ Takes a [Tracing](#tracing) block.
`int64`
### `per_image`
`int64`
PerImage is the cost of one picture, for a model that makes them
(/v1/images/generations), on top of its prompt's tokens.
### `per_mesh`
`int64`
PerMesh is the cost of one 3D model (/v1/3d/generations).
## engines.Config
### `disabled`
@@ -3221,6 +3685,14 @@ Takes a [TRTLLMConfig](#trtllmconfig) block.
Takes a [SimConfig](#simconfig) block.
### `comfyui`
`ComfyUIConfig`
ComfyUI makes pictures; offered by a node whose ComfyUI is named here.
Takes a [ComfyUIConfig](#comfyuiconfig) block.
## guard.Config
### `enabled`
+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
+1 -1
View File
@@ -323,7 +323,7 @@ func (a *Accountant) Record(res Reservation, u store.Usage) {
if u.ServedModel != "" && u.ServedModel != u.Model && a.pricing != nil {
price = a.pricing.For(u.ServedModel)
}
u.CostMicros = price.Cost(u.PromptTokens, u.CompletionTokens)
u.CostMicros = price.Cost(u.PromptTokens, u.CompletionTokens, u.Images, u.Meshes)
for _, b := range res.Budgets {
a.budgets.Add(b.Counter, b.Period, u.CostMicros)
}
+13 -3
View File
@@ -39,17 +39,27 @@ func TestCostRoundsUpSoSmallRequestsAreNotFree(t *testing.T) {
// Rounding down would make a stream of tiny requests cost nothing, which is
// exactly the traffic shape a quota exists to bound.
p := Price{PromptPer1k: 1000, CompletionPer1k: 3000}
if got := p.Cost(1, 0); got != 1 {
if got := p.Cost(1, 0, 0, 0); got != 1 {
t.Errorf("one prompt token cost %d", got)
}
if got := p.Cost(1000, 1000); got != 4000 {
if got := p.Cost(1000, 1000, 0, 0); got != 4000 {
t.Errorf("a thousand of each cost %d, want 4000", got)
}
if got := p.Cost(0, 0); got != 0 {
if got := p.Cost(0, 0, 0, 0); got != 0 {
t.Errorf("an empty request cost %d", got)
}
}
func TestAPictureCostsItsPriceOnTopOfItsPrompt(t *testing.T) {
p := Price{PromptPer1k: 1000, PerImage: 50000}
if got := p.Cost(20, 0, 3, 0); got != 20+150000 {
t.Errorf("three pictures from a 20-token prompt cost %d", got)
}
if (Price{PerImage: 1}).Zero() {
t.Error("a model priced per picture reads as free, and would pass a spent budget")
}
}
func TestZeroPricedModelsStayAvailableWhenTheBudgetIsSpent(t *testing.T) {
// This is what makes budget fallback work: a key with nothing left can
// still be served something that costs nothing.
+13 -3
View File
@@ -18,6 +18,13 @@ import (
type Price struct {
PromptPer1k int64 `koanf:"prompt_per_1k"`
CompletionPer1k int64 `koanf:"completion_per_1k"`
// PerImage is what one picture costs, for a model that makes them: a
// picture's cost is the card's time, which its prompt's few tokens do
// not measure.
PerImage int64 `koanf:"per_image"`
// PerMesh is what one 3D model costs: minutes of a card, which no
// token count measures.
PerMesh int64 `koanf:"per_mesh"`
}
// Zero reports whether this model spends nothing.
@@ -25,14 +32,17 @@ type Price struct {
// It matters beyond arithmetic: a zero-cost model stays available to a key
// whose budget is spent, which is what makes the plan's budget fallback work
// (§7.7).
func (p Price) Zero() bool { return p.PromptPer1k == 0 && p.CompletionPer1k == 0 }
func (p Price) Zero() bool {
return p.PromptPer1k == 0 && p.CompletionPer1k == 0 && p.PerImage == 0 && p.PerMesh == 0
}
// Cost computes what a request spent, rounding up.
//
// Up rather than to nearest: rounding down would make a stream of tiny requests
// free, which is exactly the shape of traffic a quota exists to bound.
func (p Price) Cost(promptTokens, completionTokens int64) int64 {
return divUp(promptTokens*p.PromptPer1k, 1000) + divUp(completionTokens*p.CompletionPer1k, 1000)
func (p Price) Cost(promptTokens, completionTokens, images, meshes int64) int64 {
return divUp(promptTokens*p.PromptPer1k, 1000) + divUp(completionTokens*p.CompletionPer1k, 1000) +
max(images, 0)*p.PerImage + max(meshes, 0)*p.PerMesh
}
func divUp(n, d int64) int64 {
+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.
+16 -3
View File
@@ -3,6 +3,7 @@ package discovery
import (
"bufio"
"os"
"regexp"
"runtime"
"strconv"
"strings"
@@ -142,17 +143,29 @@ func cpuFlagsFrom(path string) []string {
// nvidiaDriverFrom reads the loaded kernel module's version, which exists
// exactly when a driver is loaded — unlike nvidia-smi, which can be installed
// on a machine whose module failed to load.
var driverVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+(\.[0-9]+)?$`)
func nvidiaDriverFrom(path string) string {
b, err := os.ReadFile(path) //nolint:gosec // a fixed system path
if err != nil {
return ""
}
// "NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu ..."
// "NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu ...", or
// for the open module, the default since 560, "NVIDIA UNIX Open Kernel
// Module for x86_64 580.82.07 Release Build ...": the first version
// after "Module", not the word after it. Read as "for", the driver was
// unknown, and every build was tried whatever driver it needs.
fields := strings.Fields(string(b))
for i, f := range fields {
if f == "Module" && i+1 < len(fields) {
return fields[i+1]
if f != "Module" {
continue
}
for _, v := range fields[i+1:] {
if driverVersion.MatchString(v) {
return v
}
}
return ""
}
return ""
}
@@ -0,0 +1,25 @@
package discovery
import (
"os"
"path/filepath"
"testing"
)
func TestNVIDIADriverFrom(t *testing.T) {
for _, tc := range []struct{ text, want string }{
{"NVRM version: NVIDIA UNIX x86_64 Kernel Module 570.86.15 Thu Jan 23 21:30:38 UTC 2025\nGCC version: gcc version 12.2.0\n", "570.86.15"},
{"NVRM version: NVIDIA UNIX Open Kernel Module for x86_64 580.82.07 Release Build (dvs-builder@U16-I3-B03-4-3) Wed Aug 27 00:51:12 UTC 2025\n", "580.82.07"},
{"NVRM version: NVIDIA UNIX Open Kernel Module for aarch64 580.95.05 Release Build\n", "580.95.05"},
{"NVRM version: NVIDIA UNIX x86_64 Kernel Module 535.54 Mon\n", "535.54"},
{"something else\n", ""},
} {
p := filepath.Join(t.TempDir(), "version")
if err := os.WriteFile(p, []byte(tc.text), 0o600); err != nil {
t.Fatal(err)
}
if got := nvidiaDriverFrom(p); got != tc.want {
t.Errorf("%q: got %q, want %q", tc.text, got, tc.want)
}
}
}
+151
View File
@@ -0,0 +1,151 @@
package engines
import (
"context"
"fmt"
"net"
"os"
"path/filepath"
"regexp"
"strconv"
"sync/atomic"
)
// ComfyUIConfig locates a ComfyUI installation.
type ComfyUIConfig struct {
// Dir is ComfyUI's directory, where main.py is. Readable by the user
// engines run as.
Dir string `koanf:"dir"`
// Python is the interpreter of its environment; Dir's .venv when unset.
Python string `koanf:"python"`
}
// ComfyUI runs ComfyUI as an engine, behind llmgw's gate (llmgw
// comfyui-gate): ComfyUI asks for no key, so it listens on the loopback and
// the gate, which does, on the replica's port. Its replicas make pictures
// for /v1/images/generations.
type ComfyUI struct {
cfg ComfyUIConfig
installed atomic.Pointer[string]
}
// Name identifies the engine in the catalog.
func (*ComfyUI) Name() string { return "comfyui" }
// HealthPath is the gate's, which answers once ComfyUI does.
func (*ComfyUI) HealthPath() string { return "/health" }
// MetricsPath is empty: ComfyUI publishes no metrics, and the router reads
// none from it.
func (*ComfyUI) MetricsPath() string { return "" }
// ServesHTTP is true: a ComfyUI replica is one process on one node.
func (*ComfyUI) ServesHTTP(ShardSpec) bool { return true }
// UseInstalled points the engine at the environment the agent installed:
// its interpreter, with ComfyUI's code beside its bin, in comfyui/.
func (c *ComfyUI) UseInstalled(python string) { c.installed.Store(&python) }
// Configured reports whether the configuration names a ComfyUI.
func (c *ComfyUI) Configured() bool { return c.cfg.Dir != "" }
func (c *ComfyUI) dir() string {
if p := c.installed.Load(); p != nil {
return filepath.Join(filepath.Dir(filepath.Dir(*p)), "comfyui")
}
return c.cfg.Dir
}
func (c *ComfyUI) python() string {
if p := c.installed.Load(); p != nil {
return *p
}
if c.cfg.Python != "" {
return c.cfg.Python
}
return filepath.Join(c.cfg.Dir, ".venv", "bin", "python")
}
var comfyVersion = regexp.MustCompile(`__version__\s*=\s*"([^"]+)"`)
// Probe reports ComfyUI's version, from the file its build writes it to:
// importing ComfyUI to ask would load torch and look for cards.
func (c *ComfyUI) Probe(context.Context) (string, error) {
dir := c.dir()
if dir == "" {
return "", fmt.Errorf("no ComfyUI directory is configured")
}
if _, err := os.Stat(filepath.Join(dir, "main.py")); err != nil {
return "", fmt.Errorf("%s is not ComfyUI: %w", dir, err)
}
b, err := os.ReadFile(filepath.Join(dir, "comfyui_version.py"))
if err != nil {
return Unknown, nil //nolint:nilerr // installed, of a version it does not say
}
if m := comfyVersion.FindSubmatch(b); m != nil {
return string(m[1]), nil
}
return Unknown, nil
}
// Validate checks the profile: one card, on one node. ComfyUI runs a
// workflow on one device and has no tensor parallelism to spread it by.
func (*ComfyUI) Validate(p Profile, gpus []GPUInfo) error {
if p.Nodes > 1 || p.TP > 1 || p.PP > 1 || p.GPUsPerNode > 1 {
return fmt.Errorf("profile %q: ComfyUI runs on one GPU of one node", p.Name)
}
return validateCommon(p, gpus)
}
// Commands is the gate, which starts ComfyUI itself.
func (c *ComfyUI) Commands(s ShardSpec) ([]Command, error) {
dir := c.dir()
if dir == "" {
return nil, fmt.Errorf("comfyui: no ComfyUI directory is configured (engines.comfyui.dir)")
}
bin, err := os.Executable()
if err != nil {
return nil, err
}
p := s.Profile
args := []string{"comfyui-gate",
"--listen", net.JoinHostPort(s.Host(), strconv.Itoa(s.HTTPPort)),
"--model", p.ServedModelName,
"--weights", p.ModelPath,
// Relative to where engines run — their user's home — so it is
// theirs to write.
"--state", filepath.Join("comfyui", s.ReplicaID),
"--python", c.python(),
"--comfyui", dir,
"--",
}
args = append(args, p.Args...)
env := baseEnv(s)
if s.APIKey != "" {
env["LLMGW_ENGINE_KEY"] = s.APIKey
}
// A model's custom nodes and the repository a node loads its weights by,
// which only the catalog sets — a profile's env may not name them — and
// so taken before baseEnv drops them. Without the repository the gate
// links no Hugging Face cache, and sf3d's node went to the network for
// its gated repository and was refused with a 401.
for _, k := range []string{comfyNodesEnv, comfyHFRepoEnv, SharedModelsEnv} {
if v := p.Env[k]; v != "" {
env[k] = v
}
}
return []Command{{Name: "comfyui", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
}
// comfyNodesEnv is the custom nodes the catalog gives a ComfyUI model, as
// JSON; catalog.NodesEnv and comfy.NodesEnv by the same name.
const comfyNodesEnv = "LLMGW_COMFYUI_NODES"
// comfyHFRepoEnv is the repository a custom node loads the weights by name
// from; catalog.HFRepoEnv and comfy.HFRepoEnv by the same name.
const comfyHFRepoEnv = "LLMGW_COMFYUI_HF_REPO"
// SharedModelsEnv is a shared replica's members — name, source, and the
// path the agent fetched it to — as JSON; catalog.SharedModelsEnv and
// comfy.SharedModelsEnv by the same name.
const SharedModelsEnv = "LLMGW_COMFYUI_MODELS"
+99
View File
@@ -0,0 +1,99 @@
package engines
import (
"os"
"path/filepath"
"slices"
"strings"
"testing"
)
func TestComfyUIRunsBehindTheGateWithItsKeyOutOfSight(t *testing.T) {
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/comfyui"}}
cmds, err := c.Commands(ShardSpec{
ReplicaID: "r-flux-1", HTTPPort: 8100, BindAddr: "10.0.0.147", APIKey: "sekret", GPUIndices: []int{0},
Profile: Profile{Name: "p", Engine: "comfyui", ModelPath: "/w/flux.safetensors", ServedModelName: "flux",
Args: []string{"--disable-smart-memory"}},
})
if err != nil || len(cmds) != 1 {
t.Fatalf("%v %v", cmds, err)
}
args := strings.Join(cmds[0].Args, " ")
for _, want := range []string{"comfyui-gate", "--listen 10.0.0.147:8100", "--model flux", "--weights /w/flux.safetensors",
"--state comfyui/r-flux-1", "--python /opt/comfyui/.venv/bin/python", "--comfyui /opt/comfyui", "-- --disable-smart-memory"} {
if !strings.Contains(args, want) {
t.Errorf("%q not in %q", want, args)
}
}
if strings.Contains(args, "sekret") {
t.Error("the key is on the command line, where every user can read it")
}
if !slices.Contains(cmds[0].Env, "LLMGW_ENGINE_KEY=sekret") || !slices.Contains(cmds[0].Env, "CUDA_VISIBLE_DEVICES=0") {
t.Error("the key or the card is not in the environment")
}
}
func TestTheGateIsToldTheNodesAndTheRepositoryTheCatalogGives(t *testing.T) {
// The repository was dropped on the way: sf3d's node went to the network
// for its gated repository, with no cache linked, and got a 401.
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/comfyui"}}
cmds, err := c.Commands(ShardSpec{
ReplicaID: "r-sf3d-1", HTTPPort: 8100, BindAddr: "10.0.0.147", GPUIndices: []int{0},
Profile: Profile{Name: "p", Engine: "comfyui", ModelPath: "/w/sf3d/", ServedModelName: "sf3d",
Env: map[string]string{comfyNodesEnv: `[{"repo":"x"}]`, comfyHFRepoEnv: "stabilityai/stable-fast-3d"}},
})
if err != nil || len(cmds) != 1 {
t.Fatalf("%v %v", cmds, err)
}
for _, want := range []string{comfyNodesEnv + `=[{"repo":"x"}]`, comfyHFRepoEnv + "=stabilityai/stable-fast-3d"} {
if !slices.Contains(cmds[0].Env, want) {
t.Errorf("%q not in the gate's environment", want)
}
}
}
func TestComfyUIRunsOnOneCard(t *testing.T) {
c := &ComfyUI{}
if err := c.Validate(Profile{Name: "p", ModelPath: "/w", TP: 2}, h100(2)); err == nil {
t.Error("a two-card ComfyUI profile was accepted")
}
if err := c.Validate(Profile{Name: "p", ModelPath: "/w", TP: 1}, h100(1)); err != nil {
t.Error(err)
}
if _, err := c.Commands(ShardSpec{Profile: Profile{ModelPath: "/w"}}); err == nil {
t.Error("commands without a ComfyUI directory")
}
}
func TestComfyUIIsOfferedByTheVersionItsBuildWrote(t *testing.T) {
dir := t.TempDir()
c := &ComfyUI{cfg: ComfyUIConfig{Dir: dir}}
if _, err := c.Probe(t.Context()); err == nil {
t.Error("a directory without main.py passed for ComfyUI")
}
_ = os.WriteFile(filepath.Join(dir, "main.py"), nil, 0o644)
_ = os.WriteFile(filepath.Join(dir, "comfyui_version.py"), []byte("__version__ = \"0.39.2\"\n"), 0o644)
if v, err := c.Probe(t.Context()); err != nil || v != "0.39.2" {
t.Errorf("%q %v", v, err)
}
if got := NewRegistry(Config{ComfyUI: ComfyUIConfig{Dir: dir}}).ProbeConfigured(t.Context()); got["comfyui"] != "0.39.2" {
t.Errorf("a configured ComfyUI is reported as %v", got)
}
if err := UnsafeArgs("comfyui", []string{"--enable-manager"}); err == nil {
t.Error("a profile may install ComfyUI nodes")
}
}
func TestAnInstalledComfyUIIsFoundBesideItsInterpreter(t *testing.T) {
c := &ComfyUI{cfg: ComfyUIConfig{Dir: "/opt/by-hand"}}
c.UseInstalled("/opt/llmgw/engines/comfyui/0.39.2-cu13/bin/python")
cmds, err := c.Commands(ShardSpec{ReplicaID: "r", HTTPPort: 1, Profile: Profile{ModelPath: "/w", ServedModelName: "m"}})
if err != nil {
t.Fatal(err)
}
args := strings.Join(cmds[0].Args, " ")
if !strings.Contains(args, "--python /opt/llmgw/engines/comfyui/0.39.2-cu13/bin/python") ||
!strings.Contains(args, "--comfyui /opt/llmgw/engines/comfyui/0.39.2-cu13/comfyui") {
t.Errorf("the installed ComfyUI is not what runs: %s", args)
}
}
+3
View File
@@ -214,6 +214,7 @@ func NewRegistry(cfg Config) *Registry {
r.Add(&LlamaCpp{cfg: cfg.LlamaCpp})
r.Add(&TRTLLM{cfg: cfg.TRTLLM})
r.Add(&Sim{cfg: cfg.Sim})
r.Add(&ComfyUI{cfg: cfg.ComfyUI})
return r
}
@@ -262,6 +263,8 @@ type Config struct {
// offers it only when its interpreter is named here.
TRTLLM TRTLLMConfig `koanf:"trtllm"`
Sim SimConfig `koanf:"sim"`
// ComfyUI makes pictures; offered by a node whose ComfyUI is named here.
ComfyUI ComfyUIConfig `koanf:"comfyui"`
}
// --- shared helpers ---
+1 -1
View File
@@ -477,7 +477,7 @@ func TestValidateRequiresModelPath(t *testing.T) {
func TestRegistry(t *testing.T) {
r := NewRegistry(Config{})
if got := r.Names(); !slices.Equal(got, []string{"llamacpp", "sglang", "sim", "trtllm", "vllm"}) {
if got := r.Names(); !slices.Equal(got, []string{"comfyui", "llamacpp", "sglang", "sim", "trtllm", "vllm"}) {
t.Errorf("registry contents: %v", got)
}
if _, err := r.Get("vllm"); err != nil {
+13
View File
@@ -119,6 +119,19 @@ var flagsNever = map[string]map[string]string{
"--model-path": "is the model's source, set by the agent", "--served-model-name": "is set by the agent",
"--lora-paths": "loads files", "--download-dir": "writes files",
},
// ComfyUI's main.py, behind the gate that sets where it listens and
// where its files are.
"comfyui": {
"--listen": "is set by the agent", "--base-directory": "is set by the agent",
"--extra-model-paths-config": "is set by the agent",
"--output-directory": "writes files", "--temp-directory": "writes files",
"--input-directory": "reads files", "--user-directory": "writes files",
"--database-url": "writes a database elsewhere", "--front-end-root": "serves a directory",
"--enable-cors-header": "lets other sites' pages call it",
"--enable-manager": "installs nodes, which run Python",
"--tls-keyfile": "reads a file", "--tls-certfile": "reads a file",
"--comfy-api-base": "sends work to another service",
},
// trtllm-serve spells its flags with underscores, so the shared list's
// hyphenated names do not reach them.
"trtllm": {
+177
View File
@@ -0,0 +1,177 @@
package provision
import (
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
// comfyuiSource is a ComfyUI release archive as GitHub makes one, and the
// recipe pointed at it.
func comfyuiSource(t *testing.T, in *Installer, rel *release, digest string) {
t.Helper()
archive := tarGz(t, map[string]string{
"ComfyUI-0.39.2/main.py": "print('comfy')\n",
"ComfyUI-0.39.2/comfyui_version.py": "__version__ = \"0.39.2\"\n",
"ComfyUI-0.39.2/requirements.txt": "comfyui-frontend-package==1.53.10\ntorch\ntorchvision\n" +
"numpy>=1.25.0\n\n#non essential dependencies:\nkornia>=0.7.1\n",
}, nil)
rel.assets["v0.39.2.tar.gz"] = archive
sum := sha256.Sum256(archive)
if digest == "" {
digest = hex.EncodeToString(sum[:])
}
old := comfyuiReleases
comfyuiReleases = map[string]struct {
sha256 string
published string
}{"0.39.2": {digest, "2026-10-07T21:58:07Z"}}
t.Cleanup(func() { comfyuiReleases = old })
}
var turingNode = discovery.Host{OSID: "ubuntu", OSVersion: "24.04", Arch: "amd64",
NVIDIADriver: "580.82.07", MinComputeCapability: 7.5}
func TestComfyUIIsInstalledFromItsSourceCheckedAgainstTheRecipe(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
in, rel := pythonInstaller(t, turingNode, sys)
comfyuiSource(t, in, rel, "")
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
if got[0].State != Ready {
t.Fatalf("not ready: %+v", got[0])
}
dir := filepath.Join(in.Root, "comfyui", "0.39.2-cu13")
if got[0].Binary != filepath.Join(dir, "bin", "python") {
t.Errorf("binary %s, want the environment's interpreter", got[0].Binary)
}
if _, err := os.Stat(filepath.Join(dir, "comfyui", "main.py")); err != nil {
t.Errorf("ComfyUI's code is not beside the interpreter: %v", err)
}
all := strings.Join(sys.commands, "\n")
for _, want := range []string{
// Not PyPI's date, which ComfyUI has none of: the recipe's, a day on.
"--exclude-newer 2026-10-08T21:58:07Z",
"comfyui-frontend-package==1.53.10", "numpy>=1.25.0", "kornia>=0.7.1", "torch",
"--require-hashes",
} {
if !strings.Contains(all, want) {
t.Errorf("no %q in:\n%s", want, all)
}
}
if strings.Contains(all, "/pypi/") {
t.Error("PyPI was asked when ComfyUI was published")
}
}
func TestComfyUIOnAnOlderDriverTakesPyTorchsCUDA12Torch(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
host := turingNode
host.NVIDIADriver = "550.163.01"
in, rel := pythonInstaller(t, host, sys)
comfyuiSource(t, in, rel, "")
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
if got[0].State != Ready || got[0].Variant != "cu129" {
t.Fatalf("got %+v", got[0])
}
var project string
for _, c := range sys.commands {
if strings.HasPrefix(c, "pyproject: ") {
project = c
}
}
// torch from PyTorch's index alone, named once, as the build names it.
if !strings.Contains(project, `torch = { index = "extra" }`) || strings.Count(project, `"torch"`) != 1 {
t.Errorf("torch is not from PyTorch's index alone:\n%s", project)
}
}
// A V100 on driver 580: CUDA 13 runs on the driver, but neither its torch
// nor CUDA 12.9's has kernels for Volta. Tried in turn, each was installed,
// 10 GB at a time, and failed on the card.
func TestComfyUIOnAVoltaTakesPyTorchsCUDA126Torch(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
host := turingNode
host.MinComputeCapability = 7.0
in, rel := pythonInstaller(t, host, sys)
comfyuiSource(t, in, rel, "")
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
if got[0].State != Ready || got[0].Variant != "cu126" {
t.Fatalf("got %+v", got[0])
}
all := strings.Join(sys.commands, "\n")
if !strings.Contains(all, pytorchCU126) || strings.Contains(all, pytorchCU129) {
t.Errorf("torch is not CUDA 12.6's:\n%s", all)
}
}
func TestComfyUIWhoseSourceIsNotTheOneTheRecipeNamesIsNotInstalled(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
in, rel := pythonInstaller(t, turingNode, sys)
comfyuiSource(t, in, rel, strings.Repeat("ab", 32))
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}})
if got[0].State != Failed || !strings.Contains(got[0].Detail, "does not match") {
t.Fatalf("got %s %q", got[0].State, got[0].Detail)
}
for _, c := range sys.commands {
if strings.Contains(c, "pip install") {
t.Errorf("installed from a source that did not match: %s", c)
}
}
}
func TestComfyUIIsInstalledOnlyWhereItCanRun(t *testing.T) {
sys := &fakePython{fakeSystem: fakeSystem{installed: map[string]bool{}}}
in, _ := pythonInstaller(t, ubuntu, sys)
if got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.39.2"}}); got[0].State != Skipped {
t.Errorf("a node with no GPU: %+v", got[0])
}
in, _ = pythonInstaller(t, turingNode, sys)
got := in.Converge(t.Context(), []Want{{Engine: "comfyui", Version: "0.1.0"}})
if got[0].State != Unsupported || !strings.Contains(got[0].Detail, "knows the source digest of ComfyUI 0.39.2") {
t.Errorf("a version without a digest: %s %q", got[0].State, got[0].Detail)
}
}
func TestARequirementsFileIsReadForPackagesAndNothingElse(t *testing.T) {
got, err := requirementsIn("torch\ntorchvision>=0.20\nPillow\n# a comment\naiohttp>=3.11 # inline\n", pytorchPackages)
if err != nil || strings.Join(got, ",") != "Pillow,aiohttp>=3.11" {
t.Errorf("%v %v", got, err)
}
for _, bad := range []string{"-r other.txt", "--index-url https://evil", "x @ https://evil/x.whl", "-e ."} {
if _, err := requirementsIn(bad+"\n", nil); err == nil {
t.Errorf("%q was accepted", bad)
}
}
}
// A card every build runs on keeps the order it had: a Turing node's
// installed cu13 is still the first found, and is not installed again
// because a build for older cards was added after it.
func TestComfyUIBuildsAreTriedBestFirst(t *testing.T) {
variants := func(h discovery.Host) string {
builds, err := comfyuiCandidates("0.39.2", "", h)
if err != nil {
return err.Error()
}
var v []string
for _, b := range builds {
v = append(v, b.Variant)
}
return strings.Join(v, ",")
}
volta := turingNode
volta.MinComputeCapability = 7.0
older := turingNode
older.NVIDIADriver = "550.163.01"
for h, want := range map[*discovery.Host]string{&turingNode: "cu13,cu129,cu126", &volta: "cu126", &older: "cu129,cu126"} {
if got := variants(*h); got != want {
t.Errorf("cc %.1f driver %s: %s, want %s", h.MinComputeCapability, h.NVIDIADriver, got, want)
}
}
}
+83 -6
View File
@@ -66,12 +66,17 @@ func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Buil
return fmt.Errorf("installing uv, which installs %s: %s", w.Engine, uv.Detail)
}
in.progress(st, "asking PyPI when %s %s was published", r.PyPI, w.Version)
published, err := in.published(ctx, r.PyPI, w.Version)
if err != nil {
return err
var asOf string
if !b.AsOf.IsZero() {
asOf = b.AsOf.UTC().Format(time.RFC3339)
} else {
in.progress(st, "asking PyPI when %s %s was published", r.PyPI, w.Version)
published, err := in.published(ctx, r.PyPI, w.Version)
if err != nil {
return err
}
asOf = published.Add(24 * time.Hour).UTC().Format(time.RFC3339)
}
asOf := published.Add(24 * time.Hour).UTC().Format(time.RFC3339)
cache := filepath.Join(in.root(), ".uv-cache")
env := []string{
@@ -96,7 +101,14 @@ func (in *Installer) installPython(ctx context.Context, r Recipe, w Want, b Buil
}
python := filepath.Join(tmp, "bin", "python")
reqs := b.Requirements
reqs := slices.Clone(b.Requirements)
if b.Source != nil {
more, err := in.unpackSource(ctx, w, b, tmp, st)
if err != nil {
return err
}
reqs = append(reqs, more...)
}
if b.Wheel != nil {
// The engine from its GitHub release, pinned to the digest GitHub
// publishes for the file: the same trust as an archive, and uv
@@ -320,3 +332,68 @@ func (in *Installer) gpuCheck(ctx context.Context, python string, w Want, b Buil
}
return nil
}
// unpackSource fetches an engine's source archive, checks it against the
// digest the recipe holds, puts its code in the environment, and returns the
// requirements its requirements file names — less the packages a second
// index gives, which the build names itself.
func (in *Installer) unpackSource(ctx context.Context, w Want, b Build, env string, st *Status) ([]string, error) {
src := b.Source
url := src.URL
if strings.HasPrefix(url, "/") {
url = strings.TrimSuffix(in.downloadBase(), "/") + url
}
scratch := env + ".source"
_ = os.RemoveAll(scratch)
if err := os.MkdirAll(scratch, 0o755); err != nil {
return nil, err
}
defer os.RemoveAll(scratch) //nolint:errcheck // scratch
archive := filepath.Join(scratch, "source.tar.gz")
name := w.Engine + " " + w.Version + "'s source"
if err := in.download(ctx, url, name, archive, src.SHA256, st); err != nil {
return nil, err
}
in.progress(st, "unpacking %s", name)
if err := untarGz(archive, scratch); err != nil {
return nil, fmt.Errorf("unpacking %s: %w", name, err)
}
dst := filepath.Join(env, src.Dir)
if err := os.Rename(filepath.Join(scratch, src.Top), dst); err != nil {
return nil, fmt.Errorf("%s has no %s at its top: %w", name, src.Top, err)
}
if src.Requirements == "" {
return nil, nil
}
f, err := os.ReadFile(filepath.Join(dst, src.Requirements))
if err != nil {
return nil, fmt.Errorf("%s: %w", name, err)
}
return requirementsIn(string(f), b.IndexPackages)
}
// requirementsIn reads a requirements file's lines. Options (-r, -e, --index-url
// and the like) are refused: they would fetch from somewhere other than PyPI,
// or install what the lock never saw.
func requirementsIn(text string, skip []string) ([]string, error) {
var out []string
for _, line := range strings.Split(text, "\n") {
line, _, _ = strings.Cut(line, "#")
line = strings.TrimSpace(line)
if line == "" {
continue
}
if strings.HasPrefix(line, "-") || strings.Contains(line, "://") || strings.Contains(line, " @ ") {
return nil, fmt.Errorf("the requirements name %q, which is not a package from PyPI", line)
}
name := strings.ToLower(line)
if i := strings.IndexAny(name, "<>=!~;[ "); i >= 0 {
name = name[:i]
}
if slices.Contains(skip, name) {
continue
}
out = append(out, line)
}
return out, nil
}
+1
View File
@@ -53,6 +53,7 @@ func (f *fakePython) run(ctx context.Context, env []string, name string, args ..
return []byte("error: Hash mismatch for torch\n"), errExit
}
venv := filepath.Dir(filepath.Dir(args[slices.Index(args, "--python")+1]))
_ = os.WriteFile(filepath.Join(venv, "bin", "python"), []byte("#!/bin/sh\n"), 0o755)
return nil, os.WriteFile(filepath.Join(venv, "bin", "vllm"), []byte("#!/bin/sh\n"), 0o755)
case strings.HasSuffix(name, "/bin/vllm") && args[0] == "--version":
return []byte("0.30.0\n"), nil
+116
View File
@@ -13,6 +13,7 @@ import (
"fmt"
"slices"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
@@ -68,12 +69,31 @@ type Build struct {
// Note says why this build, when it is not the plain one: shown beside
// it wherever the installation is.
Note string
// Source, for a Python engine that is not on PyPI, is its own code: an
// archive pinned by its digest, unpacked into the environment, whose
// requirements file is installed with Requirements. AsOf is then the
// date its dependencies are resolved as of — the day after its version
// was published, which PyPI cannot be asked for.
Source *Source
AsOf time.Time
// FreeGB is the disk space the installation needs before it starts: a
// Python engine is several gigabytes, and one that fills the disk halfway
// leaves the node worse off than one that never began.
FreeGB int
}
// Source is an engine's code as an archive.
type Source struct {
// URL is fetched as is; SHA256 is what it must hash to, written into
// the recipe because a project's source archives come with no published
// digest of their own.
URL, SHA256 string
// Top is the archive's one top directory, put in the environment as Dir.
Top, Dir string
// Requirements is the requirements file, in Dir.
Requirements string
}
// Wheel is a package published as a GitHub release asset: the asset whose
// name starts with Prefix and ends with Suffix.
type Wheel struct {
@@ -104,6 +124,7 @@ var Recipes = map[string]Recipe{
"llamacpp": {Repo: "ggml-org/llama.cpp", Candidates: llamaCppCandidates},
"vllm": {PyPI: "vllm", Candidates: vllmCandidates},
"sglang": {PyPI: "sglang", Candidates: sglangCandidates},
"comfyui": {Candidates: comfyuiCandidates},
"uv": {Repo: "astral-sh/uv", Candidates: uvCandidates},
}
@@ -556,3 +577,98 @@ func orUnknown(s string) string {
}
return s
}
// --- ComfyUI ---
// comfyuiReleases are the ComfyUI versions this agent installs: each source
// archive's digest, taken when it was added here and confirmed from a second
// machine, and when the version was published. GitHub publishes no digest
// for a tag's archive, so a version not listed is refused rather than
// installed unchecked.
var comfyuiReleases = map[string]struct {
sha256 string
published string
}{
"0.39.2": {"1d655e22cf546700fc1d6d2536e27701695c5832a51f63bb16c41866ac09be0b", "2026-10-07T21:58:07Z"},
}
// ComfyUIVersion is the newest ComfyUI this agent installs: what a cluster
// is set to when a model first needs it.
func ComfyUIVersion() string {
newest := ""
for v := range comfyuiReleases {
if newest == "" || versionBefore(newest, v) {
newest = v
}
}
return newest
}
// ComfyUIRepo is where ComfyUI's source is published.
const ComfyUIRepo = "Comfy-Org/ComfyUI"
// comfyuiDir is where ComfyUI's code is put in its environment: main.py is
// <environment>/comfyui/main.py, beside bin/python.
const comfyuiDir = "comfyui"
// pytorchCU126 is PyTorch's CUDA 12.6 build: the last that carries kernels
// for Volta and Pascal. From torch 2.8 its CUDA 12.8 and 12.9 builds start
// at Turing (7.5), as CUDA 13 does, so a V100 has no torch but this one.
const pytorchCU126 = "https://download.pytorch.org/whl/cu126"
// comfyuiCandidates are the ComfyUI builds for a machine: its source, in a
// Python environment with the torch for the node's driver and cards —
// PyPI's, built for CUDA 13; PyTorch's CUDA 12.9 build for a driver CUDA 13
// does not run on; and its CUDA 12.6 build for cards older than Turing.
func comfyuiCandidates(version, variant string, h discovery.Host) ([]Build, error) {
if _, err := gpuArch("ComfyUI", h); err != nil {
return nil, err
}
rel, ok := comfyuiReleases[version]
if !ok {
known := make([]string, 0, len(comfyuiReleases))
for v := range comfyuiReleases {
known = append(known, v)
}
slices.Sort(known)
return nil, fmt.Errorf("this agent knows the source digest of ComfyUI %s, not of %s; "+
"install that one by hand and name it in agent.yaml (engines.comfyui.dir)",
strings.Join(known, ", "), version)
}
published, err := time.Parse(time.RFC3339, rel.published)
if err != nil {
return nil, err
}
build := func(variant string) Build {
return Build{
Variant: variant,
Python: pythonForEngines,
Source: &Source{
URL: "/" + ComfyUIRepo + "/archive/refs/tags/v" + version + ".tar.gz",
SHA256: rel.sha256, Top: "ComfyUI-" + version, Dir: comfyuiDir,
Requirements: "requirements.txt",
},
AsOf: published.Add(24 * time.Hour),
// ComfyUI is started as a script, so what the agent is given is
// the environment's interpreter; the proof is importing what it
// imports first, from where its code is.
Binary: "python",
VersionArgs: []string{"-c", `import os, sys, torch, aiohttp, safetensors
d = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(sys.executable))), "` + comfyuiDir + `")
assert os.path.isfile(os.path.join(d, "main.py")), d + " has no main.py"
print("comfyui version", "` + version + `", "on torch", torch.__version__)`},
Packages: compilerPackages,
FreeGB: 15,
GPUCheck: true,
}
}
cu129 := build("cu129")
cu129.ExtraIndex, cu129.IndexPackages = pytorchCU129, pytorchPackages
cu126 := build("cu126")
cu126.ExtraIndex, cu126.IndexPackages = pytorchCU126, pytorchPackages
return pick("ComfyUI "+version, variant, []candidate{
{build("cu13"), gpuNeeds{minDriverForCUDA13, 7.5}, "ComfyUI " + version + " on CUDA 13's torch"},
{cu129, gpuNeeds{minDriverForCUDA12, 7.5}, "ComfyUI " + version + " on CUDA 12.9's torch"},
{cu126, gpuNeeds{minDriverForCUDA12, 5.0}, "ComfyUI " + version + " on CUDA 12.6's torch"},
}, h)
}
+139
View File
@@ -0,0 +1,139 @@
package supervisor
import (
"bytes"
"os"
"path/filepath"
"time"
)
// An engine's output goes to a file of its own, not to a pipe of the
// agent's. A pipe has one reader, and that reader is the agent that
// started the engine: when that agent restarts, the engine it left running
// — and adopts a moment later — writes to a pipe nobody reads, and its next
// write past the pipe's buffer fails with EPIPE. A Python engine's progress
// bar then raises, and takes the request it was reporting on with it. A
// file has no reader to lose: the engine appends to it, every agent reads
// it, and the output from before a restart is there for the one after.
// maxOutputBytes bounds one process's file: past it the file is emptied and
// the process goes on appending to it. A progress bar rewriting itself for
// an hour would otherwise fill the disk.
const maxOutputBytes = 64 << 20
// outputPath is where a shard's process writes.
func (s *Supervisor) outputPath(shardID, name string) string {
if name != "" {
return filepath.Join(s.cfg.WorkDir, "shard-"+shardID+"."+name+".log")
}
return filepath.Join(s.cfg.WorkDir, "shard-"+shardID+".log")
}
// openOutput is the file a process is started with: new, for a process
// that is new.
func (s *Supervisor) openOutput(shardID, name string) (*os.File, error) {
return os.OpenFile(s.outputPath(shardID, name), os.O_CREATE|os.O_WRONLY|os.O_APPEND|os.O_TRUNC, 0o600) //nolint:gosec // the agent's own directory
}
// follow reads a process's file into the shard's log as it is written,
// until the shard's output is stopped and the file has been read to its
// end. A file an earlier agent began is read from the start, so the output
// from before a restart is in the log too.
func (s *Supervisor) follow(sh *Shard, path, name string) {
defer sh.outDone.Done()
f, err := os.Open(path) //nolint:gosec // the agent's own directory
if err != nil {
sh.logs.add("[" + name + "] output unavailable: " + err.Error())
return
}
defer f.Close()
var offset int64
var rest []byte
buf := make([]byte, 64<<10)
read := func() {
for {
st, err := f.Stat()
if err != nil {
return
}
if st.Size() < offset {
// Emptied, by this agent or another: begin again.
offset, rest = 0, nil
}
if st.Size() > maxOutputBytes {
_ = os.Truncate(path, 0)
offset, rest = 0, nil
continue
}
n, err := f.ReadAt(buf, offset)
if n > 0 {
offset += int64(n)
rest = append(rest, buf[:n]...)
for {
i := bytes.IndexByte(rest, '\n')
if i < 0 {
break
}
s.addLine(sh, name, rest[:i])
rest = rest[i+1:]
}
// A progress bar is a line rewritten with \r and never ended:
// its latest state is what is wanted, not a line a megabyte
// long. Kept, as llama.cpp and tqdm write them.
if len(rest) > 1<<20 {
s.addLine(sh, name, rest)
rest = nil
}
}
if err != nil || n == 0 {
return
}
}
}
t := time.NewTicker(200 * time.Millisecond)
defer t.Stop()
for {
select {
case <-sh.outStop:
read()
if len(rest) > 0 {
s.addLine(sh, name, rest)
}
return
case <-t.C:
read()
}
}
}
func (s *Supervisor) addLine(sh *Shard, name string, b []byte) {
// The last state of a line rewritten with \r, as a terminal would show.
if i := bytes.LastIndexByte(b, '\r'); i >= 0 {
b = b[i+1:]
}
line := string(b)
if name != "" {
// Prefixed so the two processes of a Ray shard stay distinguishable
// in a single log tail.
line = "[" + name + "] " + line
}
sh.logs.add(line)
s.log.Debug("engine", "shard", sh.ID, "line", line)
}
// removeOutput drops a shard's files once nothing writes to them.
func (s *Supervisor) removeOutput(shardID string) {
matches, _ := filepath.Glob(filepath.Join(s.cfg.WorkDir, "shard-"+shardID+".*log"))
for _, m := range matches {
_ = os.Remove(m)
}
_ = os.Remove(s.outputPath(shardID, ""))
}
// stopOutput has every follower read what is left and stop. Called once
// the process has ended, before the shard is done, so what it wrote last
// is in the log by the time anyone reads the log for why it ended.
func (s *Supervisor) stopOutput(sh *Shard) {
sh.outOnce.Do(func() { close(sh.outStop) })
sh.outDone.Wait()
}
+31 -33
View File
@@ -1,12 +1,10 @@
package supervisor
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
@@ -104,6 +102,12 @@ type Shard struct {
ID string
Spec engines.ShardSpec
// outStop ends the followers of the processes' output files, and
// outDone is their having read to the end.
outStop chan struct{}
outOnce sync.Once
outDone sync.WaitGroup
mu sync.Mutex
state State
pid int
@@ -248,6 +252,7 @@ func (s *Supervisor) Start(ctx context.Context, id string, spec engines.ShardSpe
logs: newRingBuffer(s.cfg.LogLines),
started: time.Now(),
done: make(chan struct{}),
outStop: make(chan struct{}),
}
s.shards[id] = sh
s.mu.Unlock()
@@ -315,14 +320,15 @@ func (s *Supervisor) launch(ctx context.Context, sh *Shard, cmds []engines.Comma
// CommandContext kills only the leader, so replace that with a group kill.
c.Cancel = func() error { return killGroup(c.Process.Pid, syscall.SIGKILL) }
stdout, err := c.StdoutPipe()
out, err := s.openOutput(sh.ID, "")
if err != nil {
cancel()
return err
}
c.Stderr = c.Stdout.(io.Writer) // interleave, as the engine writes them
if err := c.Start(); err != nil {
c.Stdout, c.Stderr = out, out // interleaved, as the engine writes them
err = c.Start()
out.Close() // the process holds its own
if err != nil {
cancel()
return fmt.Errorf("starting %s: %w", cmd.Path, err)
}
@@ -339,7 +345,8 @@ func (s *Supervisor) launch(ctx context.Context, sh *Shard, cmds []engines.Comma
"shard", sh.ID, "err", err)
}
go s.pump(sh, stdout, cmd.Name)
sh.outDone.Add(1)
go s.follow(sh, s.outputPath(sh.ID, ""), cmd.Name)
go s.wait(sh)
return nil
}
@@ -353,16 +360,18 @@ func (s *Supervisor) spawn(ctx context.Context, sh *Shard, c engines.Command) (*
s.dropTo(proc)
proc.Cancel = func() error { return killGroup(proc.Process.Pid, syscall.SIGKILL) }
stdout, err := proc.StdoutPipe()
out, err := s.openOutput(sh.ID, c.Name)
if err != nil {
return nil, err
}
proc.Stderr = proc.Stdout.(io.Writer)
if err := proc.Start(); err != nil {
proc.Stdout, proc.Stderr = out, out
err = proc.Start()
out.Close()
if err != nil {
return nil, fmt.Errorf("starting %s: %w", c.Path, err)
}
go s.pump(sh, stdout, c.Name)
sh.outDone.Add(1)
go s.follow(sh, s.outputPath(sh.ID, c.Name), c.Name)
// Reaped so it does not become a zombie; its exit does not end the shard.
go func() { _ = proc.Wait() }()
return proc, nil
@@ -395,24 +404,6 @@ func waitForPort(ctx context.Context, addr string, timeout time.Duration) (err e
}
// pump copies a process's output into the ring buffer and the log.
func (s *Supervisor) pump(sh *Shard, r io.Reader, name string) {
sc := bufio.NewScanner(r)
// Engine tracebacks and progress bars produce long lines; the default
// 64 KiB limit truncates them exactly when they matter.
sc.Buffer(make([]byte, 0, 64<<10), 1<<20)
for sc.Scan() {
line := sc.Text()
if name != "" {
// Prefixed so the two processes of a Ray shard stay distinguishable
// in a single log tail.
line = "[" + name + "] " + line
}
sh.logs.add(line)
s.log.Debug("engine", "shard", sh.ID, "line", line)
}
}
// wait reaps the process and records why it ended.
func (s *Supervisor) wait(sh *Shard) {
defer close(sh.done)
@@ -440,6 +431,7 @@ func (s *Supervisor) wait(sh *Shard) {
final, code := sh.state, sh.exitCode
sh.mu.Unlock()
s.stopOutput(sh)
s.removeState(sh)
if final == Failed {
s.log.Error("shard failed", "shard", sh.ID, "exit_code", code, "err", err,
@@ -760,6 +752,7 @@ func (s *Supervisor) writeState(sh *Shard) error {
func (s *Supervisor) removeState(sh *Shard) {
_ = os.Remove(s.statePath(sh.ID))
s.removeOutput(sh.ID)
}
// Orphan is an engine process left behind by a previous agent.
@@ -838,15 +831,18 @@ func (s *Supervisor) Adopt(o Orphan) (*Shard, error) {
logs: newRingBuffer(s.cfg.LogLines),
started: o.StartedAt,
done: make(chan struct{}),
outStop: make(chan struct{}),
adopted: true,
cmdline: o.Cmdline,
}
s.shards[o.ShardID] = sh
s.mu.Unlock()
// Output is gone with the previous agent; say so rather than presenting an
// empty log as if the engine had been silent.
sh.logs.add("[adopted from a previous agent; earlier output is not available]")
// Its output file is read from the start: what it wrote under the
// previous agent is there, and what it writes now goes on arriving.
sh.logs.add("[adopted from a previous agent]")
sh.outDone.Add(1)
go s.follow(sh, s.outputPath(sh.ID, ""), "")
go s.poll(sh)
return sh, nil
@@ -874,6 +870,7 @@ func (s *Supervisor) poll(sh *Shard) {
}
sh.mu.Unlock()
s.stopOutput(sh)
s.removeState(sh)
s.log.Info("adopted shard exited", "shard", sh.ID, "expected", expected)
return
@@ -900,6 +897,7 @@ func (s *Supervisor) KillOrphan(o Orphan) error {
}
// An already-missing state file is success: the previous agent may have
// reaped the process itself between the scan and this call.
s.removeOutput(o.ShardID)
if err := os.Remove(s.statePath(o.ShardID)); err != nil && !errors.Is(err, os.ErrNotExist) {
return err
}
@@ -1,6 +1,7 @@
package supervisor
import (
"bytes"
"context"
"errors"
"fmt"
@@ -905,3 +906,78 @@ func TestAnAdoptedShardsComputeIsKeptWithoutCrashing(t *testing.T) {
t.Fatalf("lost: %+v", again)
}
}
func TestAnAdoptedEngineGoesOnWritingAndItsEarlierOutputIsKept(t *testing.T) {
// An engine's output went to a pipe of the agent's. The agent that
// restarted and adopted the engine left that pipe with no reader, and
// the engine's next write past the pipe's buffer failed with EPIPE —
// which, in a Python engine, raised inside whatever was being reported
// on and failed the request. Output goes to a file now: a reader is not
// something a file can lose, and the lines from before the restart are
// there for the agent after it.
s, _ := newSupervisor(t)
// 256 KiB at once outgrows a pipe's buffer; a reader gone means EPIPE.
sh, err := s.Start(t.Context(), "a", engines.ShardSpec{ReplicaID: "r"},
shellCommand(`echo before-restart; sleep 1; head -c 262144 /dev/zero | tr '\0' x; echo; echo after-restart-done; sleep 30`))
if err != nil {
t.Fatal(err)
}
eventually(t, 3*time.Second, "the first line was never captured", func() bool {
return strings.Contains(strings.Join(sh.Logs(10), "\n"), "before-restart")
})
// The agent goes away without stopping the engine: nothing reads the
// output between here and the adoption.
pid := sh.Status().PID
sh.outOnce.Do(func() { close(sh.outStop) })
sh.outDone.Wait()
s2, err := New(Config{WorkDir: s.cfg.WorkDir, StopTimeout: 3 * time.Second, LogLines: 64},
&scriptedHealth{}, slog.New(slog.DiscardHandler))
if err != nil {
t.Fatal(err)
}
orphans, _ := s2.FindOrphans()
adopted, err := s2.Adopt(orphans[0])
if err != nil {
t.Fatal(err)
}
eventually(t, 5*time.Second, "the engine's output after the restart never arrived: "+strings.Join(adopted.Logs(5), " | "), func() bool {
return strings.Contains(strings.Join(adopted.Logs(64), "\n"), "after-restart-done")
})
if !strings.Contains(strings.Join(adopted.Logs(64), "\n"), "before-restart") {
t.Error("the output from before the restart is not in the adopted log")
}
if !processAlive(pid) {
t.Fatal("the engine died writing output nobody was reading")
}
if err := s2.Stop(t.Context(), "a"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(s2.outputPath("a", "")); !errors.Is(err, os.ErrNotExist) {
t.Errorf("the output file was not removed with the shard: %v", err)
}
}
func TestAnOutputFileIsEmptiedPastItsBound(t *testing.T) {
s, _ := newSupervisor(t)
path := s.outputPath("a", "")
// A file already past the bound, as a long-running engine leaves one.
big, _ := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0o644)
_, _ = big.Write(bytes.Repeat([]byte("x"), maxOutputBytes+1))
big.Close()
sh := &Shard{ID: "a", logs: newRingBuffer(8), done: make(chan struct{}), outStop: make(chan struct{})}
sh.outDone.Add(1)
go s.follow(sh, path, "")
time.Sleep(300 * time.Millisecond)
f, _ := os.OpenFile(path, os.O_WRONLY|os.O_APPEND, 0o644)
_, _ = f.WriteString("still here\n")
f.Close()
s.stopOutput(sh)
st, _ := os.Stat(path)
if st.Size() > 1024 {
t.Errorf("the file was not emptied: %d bytes", st.Size())
}
if !strings.Contains(strings.Join(sh.Logs(8), "\n"), "still here") {
t.Errorf("a line written after the file was emptied was lost: %v", sh.Logs(8))
}
}
+4
View File
@@ -243,6 +243,10 @@ var liveSettings = []string{
"firewall", "firewall_networks",
// Sent to the nodes' agents as they change.
"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",
}
+49
View File
@@ -0,0 +1,49 @@
package cli
import (
"os"
"os/signal"
"syscall"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/comfy"
)
// comfyKeyEnv carries the replica's key to the gate: the environment, which
// only the engine's user and root can read, rather than the command line,
// which every user on the node can.
const comfyKeyEnv = "LLMGW_ENGINE_KEY"
func newComfyUIGateCmd() *cobra.Command {
var cfg comfy.GateConfig
cmd := &cobra.Command{
Use: "comfyui-gate [flags] [-- main.py flags]",
Short: "Run ComfyUI as an engine: behind a key, on the loopback (started by the agent)",
Hidden: true,
Long: "Started by the node agent for a replica whose engine is comfyui. ComfyUI asks\n" +
"nobody for a key, so it listens on the loopback only and this gate on the\n" +
"replica's port, letting through, with the replica's key, only what the router\n" +
"asks of it. It also lays the fetched weights out where ComfyUI looks for them.",
RunE: func(cmd *cobra.Command, args []string) error {
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
cfg.Key = os.Getenv(comfyKeyEnv)
_ = os.Unsetenv(comfyKeyEnv)
cfg.Args = args
cfg.Log = logger()
return comfy.RunGate(ctx, cfg)
},
}
f := cmd.Flags()
f.StringVar(&cfg.Listen, "listen", "", "the replica's address")
f.StringVar(&cfg.Model, "model", "", "the model's name, for /v1/models")
f.StringVar(&cfg.Weights, "weights", "", "the fetched weights: a file or a directory")
f.StringVar(&cfg.State, "state", "", "the replica's own directory")
f.StringVar(&cfg.Python, "python", "python3", "the interpreter of ComfyUI's environment")
f.StringVar(&cfg.Dir, "comfyui", "", "ComfyUI's directory, where main.py is")
for _, n := range []string{"listen", "weights", "state", "comfyui"} {
_ = cmd.MarkFlagRequired(n)
}
return cmd
}
+4
View File
@@ -178,6 +178,7 @@ func newCtlCmd() *cobra.Command {
newRetryCmd(client),
newUpgradeCmd(client),
newJobsCmd(client, &asJSON),
newMeshJobsCmd(client, &asJSON),
newEngineCheckCmd(),
newEngineBuildCmd(client),
newToolCheckCmd(client),
@@ -331,6 +332,9 @@ func modelStatus(m adminapi.ModelView) string {
return "stopped"
case m.Ready >= m.Min && m.Min > 0:
return "ready"
case m.Min == 0 && m.Ready > 0 && m.Ready >= m.Total:
// Asked for, and up until it goes unused for long enough.
return "ready (on demand)"
case m.Total > m.Ready:
return "starting"
default:
+130
View File
@@ -0,0 +1,130 @@
package cli
import (
"encoding/json"
"fmt"
"net/url"
"time"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// newMeshJobsCmd is the 3D models clients asked for, as jobs: every owner's,
// what each is doing, and a way to stop one. (ctl jobs is the control
// plane's own work, serving and updating models.)
func newMeshJobsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
cmd := &cobra.Command{
Use: "3d",
Short: "3D models clients asked for: the jobs, how far along each is, and stopping one",
}
var status string
var limit int
list := &cobra.Command{
Use: "list",
Short: "Every owner's 3D jobs, newest first",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
q := url.Values{}
if status != "" {
q.Set("status", status)
}
if limit > 0 {
q.Set("limit", fmt.Sprint(limit))
}
var jobs []adminapi.JobView
if err := client().get(cmd.Context(), "/api/v1/client-jobs?"+q.Encode(), &jobs); err != nil {
return err
}
if *asJSON {
return emitJSON(jobs)
}
if len(jobs) == 0 {
fmt.Fprintln(cmd.OutOrStdout(), "no 3D jobs")
return nil
}
w := tabTo(cmd.OutOrStdout())
fmt.Fprintln(w, "JOB\tMODEL\tSTATUS\tPROGRESS\tOWNER\tASKED")
for _, j := range jobs {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s ago\n", j.ID, j.Model, j.Status, progressText(j),
j.Owner, time.Since(j.CreatedAt).Round(time.Second))
}
return w.Flush()
},
}
list.Flags().StringVar(&status, "status", "", "only jobs in this status: queued, running, completed, failed…")
list.Flags().IntVar(&limit, "limit", 0, "the most jobs to show (50)")
get := &cobra.Command{
Use: "get JOB",
Short: "One 3D job: its status, progress, result or error",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var j adminapi.JobView
if err := client().get(cmd.Context(), "/api/v1/client-jobs/"+url.PathEscape(args[0]), &j); err != nil {
return err
}
if *asJSON {
return emitJSON(j)
}
out := cmd.OutOrStdout()
fmt.Fprintf(out, "job %s\nmodel %s\nstatus %s\n", j.ID, j.Model, j.Status)
if p := progressText(j); p != "" {
fmt.Fprintf(out, "progress %s\n", p)
}
fmt.Fprintf(out, "owner %s (key %s)\nasked %s\n", j.Owner, j.KeyID, j.CreatedAt.UTC().Format(time.RFC3339))
if j.FinishedAt != nil && j.StartedAt != nil {
fmt.Fprintf(out, "took %s\n", j.FinishedAt.Sub(*j.StartedAt).Round(time.Second))
}
if j.OutputFileID != "" {
fmt.Fprintf(out, "result %s (%s)\n", j.OutputFileID, j.ContentType)
}
if j.Error != "" {
fmt.Fprintf(out, "error %s\n", j.Error)
}
return nil
},
}
cancel := &cobra.Command{
Use: "cancel JOB",
Short: "Stop a 3D job, in the engine's queue too",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var j adminapi.JobView
if err := client().post(cmd.Context(), "/api/v1/client-jobs/"+url.PathEscape(args[0])+"/cancel", nil, &j); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "%s is %s\n", j.ID, j.Status)
return nil
},
}
cmd.AddCommand(list, get, cancel)
return cmd
}
// progressText is a job's progress as a person reads it.
func progressText(j adminapi.JobView) string {
if len(j.Progress) == 0 {
return ""
}
var p struct {
State string `json:"state"`
Ahead int `json:"ahead"`
NodeClass string `json:"node_class"`
Step int `json:"step"`
Steps int `json:"steps"`
Percent int `json:"percent"`
}
if json.Unmarshal(j.Progress, &p) != nil {
return ""
}
switch {
case p.State == "queued" && p.Ahead > 0:
return fmt.Sprintf("queued, %d ahead", p.Ahead)
case p.State == "running" && p.Steps > 0:
return fmt.Sprintf("running %d%%, %s step %d/%d", p.Percent, p.NodeClass, p.Step, p.Steps)
case p.State == "running" && p.NodeClass != "":
return "running " + p.NodeClass
}
return p.State
}
+6
View File
@@ -56,6 +56,12 @@ func newRequestCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
fmt.Fprintf(out, "status %d\n", v.Status)
fmt.Fprintf(out, "time %d ms total: %d queued, %d to the first token\n", v.LatencyMs, v.QueueMs, v.TTFTMs)
fmt.Fprintf(out, "tokens %d in, %d out\n", v.PromptTokens, v.CompletionTokens)
if v.Images > 0 {
fmt.Fprintf(out, "images %d\n", v.Images)
}
if v.Meshes > 0 {
fmt.Fprintf(out, "meshes %d\n", v.Meshes)
}
if v.CacheHit {
fmt.Fprintf(out, "cache answered from the project's answer cache: nothing generated, nothing charged\n")
}
+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())
}
}
+78 -30
View File
@@ -3,6 +3,7 @@ package cli
import (
"encoding/json"
"fmt"
"net/url"
"os"
"os/user"
"slices"
@@ -32,7 +33,7 @@ func newModelCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
Use: "model",
Short: "Add or remove models while the cluster is running",
}
c.AddCommand(newModelAddCmd(client), newModelRemoveCmd(client), newModelPlanCmd(client),
c.AddCommand(newModelAddCmd(client), newModelPresetsCmd(client, asJSON), newModelRemoveCmd(client), newModelPlanCmd(client),
newResourcesCmd(client), newClassCmd(client), newOwnerCmd(client),
newTargetCmd(client, asJSON), newAdaptersCmd(client),
newModelServeCmd(client), newModelUpdatesCmd(client, asJSON), newModelUpdateCmd(client),
@@ -46,49 +47,74 @@ func newModelAddCmd(client func() *ctlClient) *cobra.Command {
var path string
var skipCheck bool
c := &cobra.Command{
Use: "add -f <file.yaml>",
Short: "Add a model from a definition file",
Long: "The definition is the same YAML a model takes in server.yaml, so an\n" +
Use: "add PRESET | -f <file.yaml>",
Short: "Add a model that comes with llmgw, or one from a definition file",
Long: "A preset is a model that comes with llmgw, its workflow, nodes and licence\n" +
"already found and tried: " + strings.Join(catalog.PresetNames(), ", ") + " (llmgw ctl model presets).\n" +
"Adding a ComfyUI one also has the nodes install ComfyUI, when the cluster\n" +
"has not been told otherwise.\n\n" +
"A definition is the same YAML a model takes in server.yaml, so an\n" +
"entry can be moved between the two by copying it.\n\n" +
"The catalog is rebuilt and validated before anything is written, so a\n" +
"definition that collides with an existing name, or names a fallback that\n" +
"does not exist, is refused rather than stored and then ignored on every\n" +
"start. Once accepted it takes effect on the reconciler's next pass — no\n" +
"restart — and survives one.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
if path == "" {
return fmt.Errorf("--file is required: the model definition, as YAML")
}
// Read by koanf, the same parser and the same tags that read
// server.yaml — so an entry can be moved between the two by copying
// it, and a field that works in one cannot silently mean nothing in
// the other.
k := koanf.New(".")
if err := k.Load(file.Provider(path), yaml.Parser()); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
Example: " llmgw ctl model add triposr\n" +
" llmgw ctl model add -f qwen.yaml",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
var m catalog.Model
if err := k.UnmarshalWithConf("", &m, koanf.UnmarshalConf{Tag: "koanf"}); err != nil {
return fmt.Errorf("%s: %w", path, err)
}
if m.Name == "" {
return fmt.Errorf("%s: the definition has no name", path)
switch {
case len(args) == 1 && path != "":
return fmt.Errorf("a preset or a file, not both")
case len(args) == 1:
p, ok := catalog.PresetNamed(args[0])
if !ok {
return fmt.Errorf("no preset %q: there are %s; any other model is added from a file (-f)",
args[0], strings.Join(catalog.PresetNames(), ", "))
}
// The control plane adds it, and keeps that it was added as
// a preset, by whom; one from before it could is sent the
// entry, as it was then.
var res adminapi.ModelAdded
err := client().post(cmd.Context(), "/api/v1/presets/"+url.PathEscape(p.Model.Name), []byte("{}"), &res)
if err == nil {
fmt.Fprintf(cmd.OutOrStdout(),
"%s added, and ComfyUI with it where the cluster had none. It is placed on the next\n"+
"reconcile pass and loads when first asked for; watch it with:\n llmgw ctl models\n", p.Model.Name)
return nil
}
if !olderControlPlane(err) {
return err
}
m = p.Model
case path == "":
return fmt.Errorf("a preset (%s) or --file, the model definition as YAML",
strings.Join(catalog.PresetNames(), ", "))
default:
var err error
if m, err = readModelFile(path); err != nil {
return err
}
}
// Before the catalog is touched, because a source that does not
// resolve is otherwise discovered by a replica failing to start
// minutes later on a node — with an error naming the node rather
// than the typo.
// Every source the model's profiles load, not the model's alone.
if !skipCheck {
size, err := sourcecheck.Check(cmd.Context(), m.Source)
if err != nil {
return fmt.Errorf("%w\n Pass --no-source-check to add it anyway, "+
"which is right when the mirror is not up yet or the nodes "+
"reach it by an address this machine does not.", err)
}
if _, isURL := sourcecheck.HTTPSource(m.Source); isURL {
fmt.Fprintln(cmd.OutOrStdout(), sourceNote(m.Source, size))
for _, src := range m.WeightSources() {
size, err := sourcecheck.Check(cmd.Context(), src)
if err != nil {
return fmt.Errorf("%w\n Pass --no-source-check to add it anyway, "+
"which is right when the mirror is not up yet or the nodes "+
"reach it by an address this machine does not.", err)
}
if _, isURL := sourcecheck.HTTPSource(src); isURL {
fmt.Fprintln(cmd.OutOrStdout(), sourceNote(src, size))
}
}
}
@@ -104,6 +130,9 @@ func newModelAddCmd(client func() *ctlClient) *cobra.Command {
fmt.Fprintf(cmd.OutOrStdout(),
"%s added. It will be placed on the next reconcile pass; watch it with:\n"+
" llmgw ctl models\n", m.Name)
if m.ServesComfyUI() && !m.IsExternal() {
return ensureComfyUI(cmd, client, m.Name)
}
return nil
},
}
@@ -113,6 +142,25 @@ func newModelAddCmd(client func() *ctlClient) *cobra.Command {
return c
}
// readModelFile reads a model's definition. By koanf, the same parser and
// the same tags that read server.yaml — so an entry can be moved between the
// two by copying it, and a field that works in one cannot silently mean
// nothing in the other.
func readModelFile(path string) (catalog.Model, error) {
var m catalog.Model
k := koanf.New(".")
if err := k.Load(file.Provider(path), yaml.Parser()); err != nil {
return m, fmt.Errorf("%s: %w", path, err)
}
if err := k.UnmarshalWithConf("", &m, koanf.UnmarshalConf{Tag: "koanf"}); err != nil {
return m, fmt.Errorf("%s: %w", path, err)
}
if m.Name == "" {
return m, fmt.Errorf("%s: the definition has no name", path)
}
return m, nil
}
func newModelRemoveCmd(client func() *ctlClient) *cobra.Command {
return &cobra.Command{
Use: "remove <name>",
+166
View File
@@ -0,0 +1,166 @@
package cli
import (
"encoding/json"
"fmt"
"strings"
"github.com/spf13/cobra"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
)
func newModelPresetsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
c := &cobra.Command{
Use: "presets",
Short: "The models that come with llmgw: whether each fits and is here",
Long: "Each is a catalog entry whose workflow, custom nodes, packages and licence\n" +
"were found and tried, and whose profile says the card it needs rather than\n" +
"a class, so it places on any site's cards. Add one with\n" +
"`llmgw ctl model add NAME`.\n\n" +
"While presets.auto is on, the leader adds one by itself when a card fits it\n" +
"and the catalog has nothing that makes what it makes; one somebody removed\n" +
"is not added again by itself.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
var v adminapi.PresetsView
err := client().get(cmd.Context(), "/api/v1/presets", &v)
if err != nil && !olderControlPlane(err) {
return err
}
if *asJSON && err == nil {
return emitJSON(v)
}
w := tabTo(cmd.OutOrStdout())
if err != nil {
// A control plane from before it kept them: what this
// release has, with nothing about the cluster.
fmt.Fprintln(w, "NAME\tMAKES\tCARD\tLICENCE\tWHAT IT IS FOR")
for _, p := range catalog.Presets() {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", p.Model.Name, p.Makes, presetCard(p.Model),
presetLicence(p.Model), p.Model.Description)
}
return w.Flush()
}
fmt.Fprintln(w, "NAME\tMAKES\tCARD\tLICENCE\tFITS\tHERE\tWHAT IT IS FOR")
for _, p := range v.Presets {
fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\t%s\t%s\n", p.Name, p.Makes, p.Card, licenceText(p.Licence),
orDash(strings.Join(p.FitsClasses, ",")), presetState(p), p.Description)
}
if err := w.Flush(); err != nil {
return err
}
if v.Auto {
fmt.Fprintln(cmd.OutOrStdout(), "\nAdded by themselves where a card fits (presets.auto); "+
"llmgw ctl model presets auto off stops it.")
} else {
fmt.Fprintln(cmd.OutOrStdout(), "\nNot added by themselves; llmgw ctl model presets auto on starts it.")
}
return nil
},
}
c.AddCommand(&cobra.Command{
Use: "auto on|off",
Short: "Whether the leader adds a preset by itself where a card fits it",
Args: cobra.ExactArgs(1),
ValidArgs: []string{"on", "off"},
RunE: func(cmd *cobra.Command, args []string) error {
if args[0] != "on" && args[0] != "off" {
return fmt.Errorf("on or off, not %q", args[0])
}
body, _ := json.Marshal(adminapi.PresetsAutoRequest{On: args[0] == "on"})
var v adminapi.PresetsView
if err := client().put(cmd.Context(), "/api/v1/presets/auto", body, &v); err != nil {
return err
}
if v.Auto {
fmt.Fprintln(cmd.OutOrStdout(), "Presets are added by themselves where a card fits.")
} else {
fmt.Fprintln(cmd.OutOrStdout(), "Presets are no longer added by themselves.")
}
return nil
},
})
return c
}
// olderControlPlane is an error from a control plane that has no such path:
// one from before it was added.
func olderControlPlane(err error) bool {
s := err.Error()
return strings.Contains(s, "answered with a page") || strings.HasPrefix(s, "404 Not Found: 404 page not found")
}
func presetState(p adminapi.PresetView) string {
switch {
case p.InCatalog && p.AddedBy == "auto":
return "added by itself"
case p.InCatalog && p.AddedBy != "":
return "added by " + p.AddedBy
case p.InCatalog:
return "here"
case p.Declined:
return "removed; stays out"
}
return "—"
}
func licenceText(l catalog.License) string {
if l.Commercial != nil && !*l.Commercial {
return l.Name + ", not commercial"
}
return l.Name
}
func presetCard(m catalog.Model) string {
if len(m.Profiles) == 0 || m.Profiles[0].GPU == nil {
return "—"
}
return m.Profiles[0].GPU.String()
}
func presetLicence(m catalog.Model) string {
if m.License == nil {
return "—"
}
l := m.License.Name
if c := m.License.Commercial; c != nil && !*c {
l += ", not commercial"
}
return l
}
// ensureComfyUI has the nodes install ComfyUI for a model that needs it,
// unless the cluster names a version already, or none on purpose.
func ensureComfyUI(cmd *cobra.Command, client func() *ctlClient, model string) error {
out := cmd.OutOrStdout()
path := []string{"engines", catalog.EngineComfyUI}
var cur adminapi.SettingsView
if err := client().get(cmd.Context(), "/api/v1/settings", &cur); err != nil {
return err
}
doc, err := settings.FromBytes("settings", []byte(cur.Body))
if err != nil {
return err
}
version := provision.ComfyUIVersion()
if n := doc.Node(path); n != nil {
if n.Value == "" {
fmt.Fprintf(out, "engines.comfyui is set to none, so the nodes install no ComfyUI for it: set a version "+
"(llmgw ctl config set engines.comfyui %s) or name one installed by hand in agent.yaml\n", version)
}
return nil
}
fmt.Fprintf(out, "Nodes with an NVIDIA GPU install ComfyUI %s for it, about 10 GB each:\n", version)
if err := doc.SetYAML(path, version); err != nil {
return err
}
b, err := doc.Bytes()
if err != nil {
return err
}
return putSettings(cmd, client, cur, b, "ComfyUI for "+model)
}
+128
View File
@@ -0,0 +1,128 @@
package cli
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
)
// presetAdmin is an admin API holding settings, which records the models
// added and the settings put.
type presetAdmin struct {
// api is a control plane that adds presets itself; without it, one
// from before, which answers their paths with a 404.
api bool
byAPI []string
mu sync.Mutex
settings string
added []catalog.Model
put []string
}
func (a *presetAdmin) client(t *testing.T) func() *ctlClient {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
body, _ := io.ReadAll(r.Body)
w.Header().Set("Content-Type", "application/json")
switch {
case a.api && r.Method == http.MethodPost && strings.HasPrefix(r.URL.Path, "/api/v1/presets/"):
name := strings.TrimPrefix(r.URL.Path, "/api/v1/presets/")
a.byAPI = append(a.byAPI, name)
_ = json.NewEncoder(w).Encode(adminapi.ModelAdded{Added: name})
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/models":
var m catalog.Model
_ = json.Unmarshal(body, &m)
a.added = append(a.added, m)
_ = json.NewEncoder(w).Encode(map[string]string{"name": m.Name})
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/settings":
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 1, Body: a.settings})
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/settings":
var req adminapi.SettingsRequest
_ = json.Unmarshal(body, &req)
a.put = append(a.put, req.Body)
a.settings = req.Body
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 2, Body: req.Body})
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return func() *ctlClient { return newCtlClient(strings.TrimPrefix(srv.URL, "http://"), "") }
}
func runModelAdd(t *testing.T, client func() *ctlClient, args ...string) (string, error) {
t.Helper()
cmd := newModelAddCmd(client)
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&out)
cmd.SetArgs(append(args, "--no-source-check"))
err := cmd.ExecuteContext(t.Context())
return out.String(), err
}
func TestAPresetIsAddedByTheControlPlane(t *testing.T) {
a := &presetAdmin{api: true}
out, err := runModelAdd(t, a.client(t), "triposr")
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
if len(a.byAPI) != 1 || a.byAPI[0] != "triposr" || len(a.added) != 0 || len(a.put) != 0 {
t.Errorf("by the API %v; sent the entry %v; settings %v", a.byAPI, a.added, a.put)
}
}
// A control plane from before presets were its own is sent the entry, and
// ComfyUI set beside it, as the release that brought presets did.
func TestAPresetIsAddedByNameAndHasTheNodesInstallComfyUI(t *testing.T) {
a := &presetAdmin{settings: "router:\n retries: 2\n"}
out, err := runModelAdd(t, a.client(t), "triposr")
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
if len(a.added) != 1 || a.added[0].Name != "triposr" || a.added[0].ComfyUI.Workflow != "triposr" {
t.Fatalf("added %+v", a.added)
}
if len(a.put) != 1 || !strings.Contains(a.put[0], "comfyui: 0.39.2") || !strings.Contains(a.put[0], "retries: 2") {
t.Fatalf("settings put %q", a.put)
}
if !strings.Contains(out, "install ComfyUI 0.39.2") {
t.Errorf("not said:\n%s", out)
}
}
func TestAClusterThatNamesItsComfyUIKeepsIt(t *testing.T) {
for _, settings := range []string{"engines:\n comfyui: 0.38.0\n", "engines:\n comfyui: \"\"\n"} {
a := &presetAdmin{settings: settings}
out, err := runModelAdd(t, a.client(t), "flux-schnell")
if err != nil {
t.Fatalf("%v\n%s", err, out)
}
if len(a.put) != 0 {
t.Errorf("%q: settings changed to %q", settings, a.put)
}
if none := strings.Contains(settings, `""`); none != strings.Contains(out, "set to none") {
t.Errorf("%q: %s", settings, out)
}
}
}
func TestAnUnknownPresetNamesTheOnesThereAre(t *testing.T) {
a := &presetAdmin{}
_, err := runModelAdd(t, a.client(t), "sdxl")
if err == nil || !strings.Contains(err.Error(), "flux-schnell, triposr") || len(a.added) != 0 {
t.Errorf("%v, added %v", err, a.added)
}
if _, err := runModelAdd(t, a.client(t), "triposr", "-f", "x.yaml"); err == nil {
t.Error("a preset and a file were both taken")
}
}
+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")
+1
View File
@@ -54,6 +54,7 @@ func Root() *cobra.Command {
newMirrorCmd(),
newBundleCmd(),
newSimEngineCmd(),
newComfyUIGateCmd(),
newDoctorCmd(),
newNodeCmd(),
newSelfUpgradeCmd(),
+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)
}
}
+55 -10
View File
@@ -210,6 +210,10 @@ func newServerCmd() *cobra.Command {
if err != nil {
return err
}
if st := rt.Store(); st != nil {
// Each control plane raises the same events; one sends each.
notifier.SetClaimer(st)
}
rt.SetNotifier(notifier)
if gate != nil && notifier != nil {
gate.SetJudgeAlarm(func(model string, err error) {
@@ -260,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()
@@ -461,6 +466,14 @@ func newServerCmd() *cobra.Command {
}
api.SetModels(svc)
catalogSvc = svc
// The models that come with llmgw: added by the leader
// when a node fits one and the catalog makes nothing of
// its kind, unless somebody removed it or turned it off.
api.SetPresets(st, func() adminapi.PresetConfig {
c := current.Load()
return adminapi.PresetConfig{Auto: c.Presets.Auto, ComfyUI: c.Engines[catalog.EngineComfyUI]}
})
go runPresetsPass(ctx, api, admin.Leadership(), log)
// And the mirror page, so a model that has just been
// fetched can be served without writing YAML by hand.
rt.SetCatalogAdmin(svc)
@@ -766,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()
@@ -1169,18 +1185,18 @@ func servableCheck(llamaVersion string) func(context.Context, catalog.Model) err
if llamaVersion == "" {
return nil
}
usesLlama := false
// Each source a llama.cpp profile loads: its own, or the model's.
for _, p := range m.Profiles {
if p.Engine == "llamacpp" {
usesLlama = true
if p.Engine != "llamacpp" {
continue
}
url, ok := sourcecheck.HTTPSource(m.SourceOf(p))
if !ok || !gguf.IsGGUF(url) {
continue
}
if v := gguf.Check(ctx, url, llamaVersion); !v.Servable {
return fmt.Errorf("%s cannot be served: %s", m.Name, v.Reason)
}
}
url, ok := sourcecheck.HTTPSource(m.Source)
if !usesLlama || !ok || !gguf.IsGGUF(url) {
return nil
}
if v := gguf.Check(ctx, url, llamaVersion); !v.Servable {
return fmt.Errorf("%s cannot be served: %s", m.Name, v.Reason)
}
return nil
}
@@ -1277,3 +1293,32 @@ func embedder(replay func(ctx context.Context, path, model string, body []byte)
return res.Data[0].Embedding, nil
}
}
// presetsEvery is how often the leader looks for presets to add; the first
// look waits as long, for the nodes to have registered their cards.
const presetsEvery = time.Minute
// runPresetsPass has the leader add the presets a node fits and the catalog
// lacks, now and then.
func runPresetsPass(ctx context.Context, api *adminapi.API, lead *control.Leadership, log *slog.Logger) {
t := time.NewTicker(presetsEvery)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
if lead != nil && !lead.IsLeader() {
continue
}
added, err := api.PresetsPass(ctx)
for _, name := range added {
log.Info("added a preset by itself: a node fits it and the catalog made nothing of its kind",
"model", name, "off_switch", "presets.auto")
}
if err != nil {
log.Warn("could not add a preset", "err", err)
}
}
}
+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
}
+2
View File
@@ -52,6 +52,8 @@ func TestModelStatusSeparatesWaitingFromBroken(t *testing.T) {
{"nothing could be placed", adminapi.ModelView{Min: 1, Ready: 0, Total: 0}, "not placed"},
{"deliberately not running", adminapi.ModelView{Min: 0, Ready: 0, Total: 0}, "stopped"},
{"short but rebuilding", adminapi.ModelView{Min: 2, Ready: 1, Total: 2}, "starting"},
{"woken by a request", adminapi.ModelView{Min: 0, Ready: 1, Total: 1}, "ready (on demand)"},
{"waking", adminapi.ModelView{Min: 0, Ready: 0, Total: 1}, "starting"},
} {
t.Run(tc.name, func(t *testing.T) {
if got := modelStatus(tc.view); got != tc.want {
+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()
}
+18 -3
View File
@@ -90,7 +90,10 @@ type consoleData struct {
// federation is the other llmgw clusters whose models are offered
// here; empty on a control plane without a store.
federation adminapi.FederationView
err error
// presets is the models that come with llmgw as they stand here; nil
// from a control plane that cannot say.
presets *adminapi.PresetsView
err error
}
type (
@@ -275,6 +278,10 @@ func (m consoleModel) fetch() tea.Cmd {
_ = c.get(ctx, "/api/v1/jobs", &d.jobs)
_ = c.get(ctx, "/api/v1/audit?since=7d&limit=300", &d.audit)
_ = c.get(ctx, "/api/v1/federation", &d.federation)
var presets adminapi.PresetsView
if c.get(ctx, "/api/v1/presets", &presets) == nil {
d.presets = &presets
}
return consoleDataMsg(d)
}
}
@@ -599,6 +606,10 @@ func (m consoleModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
m.hf.models, m.hf.err, m.row = nil, nil, 0
return m, m.fetchHF()
}
case "p":
if m.menu == cModels && m.inPanel && m.data.presets != nil {
m.autoPresetsConfirm()
}
case "a":
switch m.menu {
case cQuotas:
@@ -1237,9 +1248,13 @@ func (m consoleModel) panel() (title, body, keys string) {
strings.Join(md.Profiles, ","), upd, warn})
}
if m.inPanel {
keys = "↑↓ row · Enter actions · A add from a file · ← back · Q leave"
keys = "↑↓ row · Enter actions · A add a model · ← back · Q leave"
if m.data.presets != nil {
keys = "↑↓ row · Enter actions · A add a model · P pictures and 3D added by themselves · ← back · Q leave"
}
}
return "Models", "\n" + table([]string{"MODEL", "READY", "STATUS", "CLASS", "PROFILES", "UPDATE", ""}, rows, m.row, pw, m.inPanel), keys
return "Models", "\n" + table([]string{"MODEL", "READY", "STATUS", "CLASS", "PROFILES", "UPDATE", ""}, rows, m.row, pw, m.inPanel) +
presetsBlock(m.data.presets, pw), keys
case cReplicas:
var rows [][]string
for _, r := range m.data.replicas {
+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)
}
}
+30 -6
View File
@@ -12,6 +12,7 @@ import (
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/mirror"
)
@@ -253,19 +254,42 @@ func (m consoleModel) mirrorPanel(pw int) (string, string, string) {
return "Mirror", head + table([]string{"MODEL", "VERSION", "FILE", "STATE", "SIZE", "USED BY"}, rows, m.row, pw, m.inPanel), keys
}
// addModelForm adds a model from a definition file on this machine: the
// same YAML a model takes in server.yaml.
// fromFile is the add-model choice that reads a definition file rather
// than naming a preset.
const fromFile = "from a file"
// addModelForm adds a model that comes with llmgw, or one from a definition
// file on this machine: the same YAML a model takes in server.yaml.
func (m *consoleModel) addModelForm() {
server := "--server=" + m.client.base
var note strings.Builder
note.WriteString("A model that comes with llmgw, tried and with its licence:\n")
for _, p := range catalog.Presets() {
fmt.Fprintf(&note, " %s: %s, %s, %s\n", p.Model.Name, p.Makes, presetCard(p.Model), presetLicence(p.Model))
}
note.WriteString("A ComfyUI one has the nodes install ComfyUI if the cluster has none.\n" +
"Or a definition file: the YAML a model takes in server.yaml's models.")
var choices []string
for _, n := range catalog.PresetNames() {
choices = append(choices, presetChoice(m.data.presets, n))
}
m.openForm(&formDialog{title: "Add a model",
note: "From a definition file on this machine: the YAML a model takes in\nserver.yaml's models. Serve a model, under Do, needs none.",
note: note.String(),
fields: []formField{
textF("file", "", "like /root/qwen.yaml"),
choiceF("model", append(choices, fromFile), choices[0]),
textF("file", "", "for "+fromFile+": like /root/qwen.yaml"),
},
submit: func(m *consoleModel, v []string) (tea.Cmd, error) {
if _, err := os.Stat(v[0]); err != nil {
if v[0] != fromFile {
// The name, without what the choice says beside it.
return m.addPreset(strings.Fields(v[0])[0]), nil
}
if v[1] == "" {
return nil, fmt.Errorf("a file, or a model that comes with llmgw")
}
if _, err := os.Stat(v[1]); err != nil {
return nil, err
}
return m.runCLI("Add a model", "ctl", server, "model", "add", "-f", v[0]), nil
return m.runCLI("Add a model", "ctl", server, "model", "add", "-f", v[1]), nil
}})
}
+128
View File
@@ -0,0 +1,128 @@
package cli
import (
"encoding/json"
"net/http"
"net/url"
"strings"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
// presetsBlock is the models that come with llmgw, under the models table:
// whether each fits a card here and whether it is in the catalog, and
// whether the leader adds them by itself. Empty from a control plane that
// cannot say.
func presetsBlock(v *adminapi.PresetsView, width int) string {
if v == nil || len(v.Presets) == 0 {
return ""
}
var rows [][]string
var why []string
for _, p := range v.Presets {
rows = append(rows, []string{p.Name, p.Makes, orDash(strings.Join(p.FitsClasses, ",")), presetState(p)})
// Why the leader leaves it, under the table: a reason is longer than
// any column the panel has room for. Automatic adding being off is
// said once, below.
if p.Blocked != "" && !strings.HasPrefix(p.Blocked, "presets.auto") && !p.Declined {
why = append(why, " "+wrap(p.Name+": "+p.Blocked, width-4, " "))
}
}
auto := "Added by themselves where a card fits · P stops that"
if !v.Auto {
auto = "Not added by themselves · P starts that · A adds one"
}
out := "\n\n Pictures and 3D models that come with llmgw\n" +
table([]string{"PRESET", "MAKES", "FITS", "HERE"}, rows, -1, width, false)
if len(why) > 0 {
out += "\n" + sDim.Render(strings.Join(why, "\n"))
}
return out + "\n " + sDim.Render(auto)
}
// presetChoice is how a preset is offered in the add dialog: its name, and
// where it stands when that is worth knowing before choosing it.
func presetChoice(v *adminapi.PresetsView, name string) string {
if v == nil {
return name
}
for _, p := range v.Presets {
if p.Name != name {
continue
}
switch {
case p.InCatalog:
return name + " (already here)"
case presetAddsComfyUI(v, p):
return name + " (installs ComfyUI too)"
case !p.Fits:
return name + " (no card here fits it)"
case p.Declined:
return name + " (removed before)"
}
}
return name
}
// presetAddsComfyUI is whether adding a preset by hand also sets
// engines.comfyui, which has the GPU nodes install it and the control planes
// restart on the setting.
func presetAddsComfyUI(v *adminapi.PresetsView, p adminapi.PresetView) bool {
return v != nil && !p.InCatalog && v.ComfyUI == ""
}
// addPreset adds a preset through the control plane, or, from one from
// before it could, the way the release that brought presets did. One whose
// adding installs ComfyUI and restarts the control planes is asked about
// first.
func (m *consoleModel) addPreset(name string) tea.Cmd {
if m.data.presets != nil {
for _, p := range m.data.presets.Presets {
if p.Name == name && presetAddsComfyUI(m.data.presets, p) {
m.confirmD = &confirmDialog{title: "Add " + name + " and ComfyUI",
text: "The cluster installs no ComfyUI yet. Adding " + name + " sets engines.comfyui:\n" +
"every GPU node installs it, about 10 GB each, and the control planes restart\n" +
"on the setting, standbys first. Engines keep serving.",
yes: func(m *consoleModel) tea.Cmd { return m.postPreset(name) }}
return nil
}
}
}
return m.postPreset(name)
}
func (m *consoleModel) postPreset(name string) tea.Cmd {
if m.data.presets == nil {
return m.runCLI("Add "+name, "ctl", "--server="+m.client.base, "model", "add", name)
}
return m.call("Add "+name, http.MethodPost, "/api/v1/presets/"+url.PathEscape(name), map[string]string{},
func(_ json.RawMessage) string {
return name + " is added, and ComfyUI with it where the cluster had none.\n" +
"It is placed on the next pass, and loads when first asked for."
})
}
// autoPresetsConfirm turns the adding of presets by themselves on or off,
// after saying what that does.
func (m *consoleModel) autoPresetsConfirm() {
on := !m.data.presets.Auto
title, text := "Stop adding pictures and 3D models by themselves",
"Presets already added stay. A preset is added only when somebody adds it."
if on {
title, text = "Add pictures and 3D models by themselves",
"Where a card fits one and the catalog has nothing that makes what it makes, the leader adds it.\n"+
"That also has every GPU node install ComfyUI, about 10 GB each, and the mirror fetch its weights.\n"+
"A preset somebody removed is not added again."
}
m.confirmD = &confirmDialog{title: title, text: text, yes: func(m *consoleModel) tea.Cmd {
return m.call(title, http.MethodPut, "/api/v1/presets/auto", adminapi.PresetsAutoRequest{On: on},
func(_ json.RawMessage) string {
if on {
return "Presets are added by themselves where a card fits."
}
return "Presets are no longer added by themselves."
})
}}
}
+63
View File
@@ -0,0 +1,63 @@
package cli
import (
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
)
func presetsHere() *adminapi.PresetsView {
return &adminapi.PresetsView{Auto: true, Presets: []adminapi.PresetView{
{Name: "flux-schnell", Makes: "pictures", Card: "cc 7.0+, 24 GB+", Fits: true, FitsClasses: []string{"v100-32g"},
Blocked: "the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that"},
{Name: "triposr", Makes: "meshes", Card: "cc 7.0+, 8 GB+", Fits: true, InCatalog: true, AddedBy: "auto"},
}}
}
func TestThePresetsAreOnTheModelsScreen(t *testing.T) {
m, _ := consoleWith(t, cModels)
m.data.presets = presetsHere()
v := m.View()
for _, want := range []string{"Pictures and 3D models that come with llmgw", "flux-schnell", "v100-32g",
"installs no ComfyUI", "added by itself", "P pictures and 3D"} {
if !strings.Contains(v, want) {
t.Errorf("%q not shown:\n%s", want, v)
}
}
// A control plane that cannot say shows none of it.
m.data.presets = nil
if v := m.View(); strings.Contains(v, "come with llmgw") || strings.Contains(v, "P pictures") {
t.Errorf("presets shown from nothing:\n%s", v)
}
}
func TestAPresetThatInstallsComfyUIIsAddedOnlyWhenConfirmed(t *testing.T) {
m, s := consoleWith(t, cModels)
m.data.presets = presetsHere()
var tm tea.Model = m
tm = press(tm, "a")
f := tm.(consoleModel).formD
if f == nil || !strings.Contains(f.fields[0].value(), "installs ComfyUI too") {
t.Fatalf("no add dialog saying what flux-schnell brings: %+v", f)
}
tm = press(tm, "enter", "enter", "enter")
if tm.(consoleModel).confirmD == nil || s.last() != "" {
t.Fatalf("added without asking: %q", s.last())
}
tm = pressRun(tm, "y")
if got := s.last(); got != "POST /api/v1/presets/flux-schnell {}" {
t.Fatalf("sent %q", got)
}
tm = press(tm, "esc", "p") // past what the add said
if c := tm.(consoleModel).confirmD; c == nil || !strings.HasPrefix(c.title, "Stop adding") {
t.Fatalf("P did not ask to stop: %+v", c)
}
_ = pressRun(tm, "y")
if got := s.last(); got != `PUT /api/v1/presets/auto {"on":false}` {
t.Fatalf("sent %q", got)
}
}
+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)
}
+526
View File
@@ -0,0 +1,526 @@
// Package clientapi describes the API clients use, the one on :8080 — OpenAI's
// shape and what llmgw adds to it — for its OpenAPI document.
//
// The types here are the bodies as they go over the wire, written for the
// document: the handlers read a request's routing fields and pass the rest
// to the engine as it came, so a request may carry more than is listed, and
// an engine's answer may say more than is listed. The route table is checked
// against the router's own by a test, so an endpoint cannot be added without
// being described.
package clientapi
import "encoding/json"
// Error is every refusal: OpenAI's shape.
type Error struct {
Error ErrorBody `json:"error"`
}
// ErrorBody says why.
type ErrorBody struct {
Message string `json:"message"`
// Type is invalid_request_error, server_error, rate_limit_exceeded …
Type string `json:"type"`
// Code, where there is one a client branches on: model_not_found,
// content_policy_violation, job_not_found, system_stopped.
Code *string `json:"code"`
}
// ChatMessage is one turn.
type ChatMessage struct {
// Role is system, user, assistant or tool.
Role string `json:"role"`
// Content is a string, or a list of parts: text, and image_url for a
// model with vision.
Content json.RawMessage `json:"content"`
Name string `json:"name,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
// StreamOptions asks for usage in the last chunk of a stream.
type StreamOptions struct {
IncludeUsage bool `json:"include_usage"`
}
// ChatCompletionRequest is a chat turn asked of a model.
type ChatCompletionRequest struct {
// Model is a model's name or alias, or an adapter's name.
Model string `json:"model"`
Messages []ChatMessage `json:"messages"`
// Stream answers as server-sent events.
Stream bool `json:"stream,omitempty"`
StreamOptions *StreamOptions `json:"stream_options,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
MaxCompletionTokens int `json:"max_completion_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
Seed *int64 `json:"seed,omitempty"`
// Stop is a string or a list of them.
Stop json.RawMessage `json:"stop,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
ToolChoice json.RawMessage `json:"tool_choice,omitempty"`
ResponseFormat json.RawMessage `json:"response_format,omitempty"`
// User is the client's own name for whoever it serves, kept with the
// usage.
User string `json:"user,omitempty"`
// Metadata are tags kept with the usage, never sent to the engine.
Metadata map[string]string `json:"metadata,omitempty"`
}
// Usage is what a request used.
type Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
}
// ChatChoice is one answer.
type ChatChoice struct {
Index int `json:"index"`
Message ChatMessage `json:"message"`
// FinishReason is stop, length or tool_calls.
FinishReason string `json:"finish_reason"`
}
// ChatCompletion is a model's answer.
type ChatCompletion struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []ChatChoice `json:"choices"`
Usage Usage `json:"usage"`
}
// CompletionRequest is a prompt to continue.
type CompletionRequest struct {
Model string `json:"model"`
// Prompt is a string or a list of them.
Prompt json.RawMessage `json:"prompt"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature *float64 `json:"temperature,omitempty"`
Stream bool `json:"stream,omitempty"`
Stop json.RawMessage `json:"stop,omitempty"`
Seed *int64 `json:"seed,omitempty"`
}
// CompletionChoice is one continuation.
type CompletionChoice struct {
Index int `json:"index"`
Text string `json:"text"`
FinishReason string `json:"finish_reason"`
}
// Completion is a continued prompt.
type Completion struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
Model string `json:"model"`
Choices []CompletionChoice `json:"choices"`
Usage Usage `json:"usage"`
}
// EmbeddingRequest asks for vectors.
type EmbeddingRequest struct {
Model string `json:"model"`
// Input is a string or a list of them; each is embedded on its own, and
// the longest must fit the model's context.
Input json.RawMessage `json:"input"`
EncodingFormat string `json:"encoding_format,omitempty"`
Dimensions int `json:"dimensions,omitempty"`
}
// Embedding is one input's vector.
type Embedding struct {
Object string `json:"object"`
Index int `json:"index"`
Embedding []float64 `json:"embedding"`
}
// EmbeddingList is every input's vector.
type EmbeddingList struct {
Object string `json:"object"`
Data []Embedding `json:"data"`
Model string `json:"model"`
Usage Usage `json:"usage"`
}
// RerankRequest orders documents by a query, for a reranker vLLM serves.
type RerankRequest struct {
Model string `json:"model"`
Query string `json:"query"`
Documents []string `json:"documents"`
TopN int `json:"top_n,omitempty"`
}
// ScoreRequest scores pairs of texts, for a scorer vLLM serves.
type ScoreRequest struct {
Model string `json:"model"`
// TextA and TextB are strings or lists of them.
TextA json.RawMessage `json:"text_1"`
TextB json.RawMessage `json:"text_2"`
}
// TranscriptionForm is speech to text, sent as a form.
type TranscriptionForm struct {
// File is the audio.
File []byte `json:"file"`
Model string `json:"model"`
Language string `json:"language,omitempty"`
Prompt string `json:"prompt,omitempty"`
ResponseFormat string `json:"response_format,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
}
// Transcription is the text.
type Transcription struct {
Text string `json:"text"`
}
// ImageRequest asks for pictures, of a model that makes them.
type ImageRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
// N is how many, at most 4.
N int `json:"n,omitempty"`
// Size is WIDTHxHEIGHT, each a multiple of 16 from 256 to 2048.
Size string `json:"size,omitempty"`
// ResponseFormat is b64_json, the only one offered.
ResponseFormat string `json:"response_format,omitempty"`
Seed *int64 `json:"seed,omitempty"`
Steps int `json:"steps,omitempty"`
NegativePrompt string `json:"negative_prompt,omitempty"`
Guidance *float64 `json:"guidance,omitempty"`
}
// Image is one picture.
type Image struct {
B64JSON string `json:"b64_json"`
}
// ImagesResponse is the pictures made; x-llmgw-seed says the seed.
type ImagesResponse struct {
Created int64 `json:"created"`
Data []Image `json:"data"`
}
// ModerationRequest asks the guardrails' judge.
type ModerationRequest struct {
// Input is a string or up to 32 of them.
Input json.RawMessage `json:"input"`
Model string `json:"model,omitempty"`
}
// ModerationResult is one input's verdict.
type ModerationResult struct {
Flagged bool `json:"flagged"`
Categories map[string]bool `json:"categories"`
CategoryScores map[string]float64 `json:"category_scores"`
}
// ModerationResponse is every input's verdict.
type ModerationResponse struct {
ID string `json:"id"`
Model string `json:"model"`
Results []ModerationResult `json:"results"`
}
// ResponsesRequest is OpenAI's Responses API, translated to chat completions.
type ResponsesRequest struct {
Model string `json:"model"`
// Input is a string or a list of items: messages, function calls and
// their outputs.
Input json.RawMessage `json:"input"`
Instructions string `json:"instructions,omitempty"`
Stream bool `json:"stream,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
// Text carries format: a JSON schema the answer follows.
Text json.RawMessage `json:"text,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
}
// MessagesRequest is Anthropic's Messages API, translated to chat completions.
type MessagesRequest struct {
Model string `json:"model"`
Messages json.RawMessage `json:"messages"`
System json.RawMessage `json:"system,omitempty"`
MaxTokens int `json:"max_tokens"`
Stream bool `json:"stream,omitempty"`
Tools json.RawMessage `json:"tools,omitempty"`
}
// TokenCount is how many tokens a Messages request would be.
type TokenCount struct {
InputTokens int `json:"input_tokens"`
}
// License is the terms what a model makes comes under.
type License struct {
// ID is SPDX where there is one, else the licence's own short name.
ID string `json:"id,omitempty"`
Name string `json:"name"`
URL string `json:"url,omitempty"`
Notice string `json:"notice,omitempty"`
// Commercial is whether the output may be used commercially; absent is
// not said.
Commercial *bool `json:"commercial,omitempty"`
// ExcludedRegions are ISO 3166 codes, EU for the union, where it grants
// nothing.
ExcludedRegions []string `json:"excluded_regions,omitempty"`
RevenueCapUSD int64 `json:"revenue_cap_usd,omitempty"`
Attribution string `json:"attribution,omitempty"`
}
// WorkflowInfo is what a ComfyUI model takes and makes, for a run at
// /v1/workflows/runs.
type WorkflowInfo struct {
// Output is pictures, meshes, video, audio or files.
Output string `json:"output"`
Inputs []WorkflowInput `json:"inputs"`
}
// WorkflowInput is one input a workflow takes.
type WorkflowInput struct {
Name string `json:"name"`
// Type is text, int, float, bool, choice, image, mask, audio, video or
// file; the last five are sent as base64, a data URL, or a file's id.
Type string `json:"type"`
Required bool `json:"required,omitempty"`
Default any `json:"default,omitempty"`
Min *float64 `json:"min,omitempty"`
Max *float64 `json:"max,omitempty"`
Choices []string `json:"choices,omitempty"`
MaxMB int `json:"max_mb,omitempty"`
Doc string `json:"doc,omitempty"`
}
// RunRequest asks a ComfyUI model to run its workflow.
type RunRequest struct {
Model string `json:"model"`
// Inputs are by name, as the model's workflow lists them in /v1/models.
Inputs map[string]any `json:"inputs"`
// Seed is the workflow's {{seed}}, where it has one; random otherwise.
Seed *int64 `json:"seed,omitempty"`
}
// ModelInfo is a model a key may use.
type ModelInfo struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
// Loaded is whether a replica is up now; a model that loads when asked
// is listed asleep too, and its first request waits for it to load.
Loaded bool `json:"loaded"`
// ContextLength and MaxOutputTokens, where the catalog says.
ContextLength int `json:"context_length,omitempty"`
MaxOutputTokens int `json:"max_output_tokens,omitempty"`
Description string `json:"description,omitempty"`
// Capabilities: tools, vision, embeddings, images, meshes, workflows.
Capabilities map[string]bool `json:"capabilities"`
License *License `json:"license,omitempty"`
// Workflow is what a ComfyUI model takes and makes.
Workflow *WorkflowInfo `json:"workflow,omitempty"`
}
// ModelList is the models a key may use.
type ModelList struct {
Object string `json:"object"`
Data []ModelInfo `json:"data"`
}
// FileForm is a file uploaded, as a form.
type FileForm struct {
File []byte `json:"file"`
// Purpose is batch for a batch's input.
Purpose string `json:"purpose"`
}
// File is a file kept: a batch's input or output, a job's result.
type File struct {
ID string `json:"id"`
Object string `json:"object"`
Bytes int64 `json:"bytes"`
CreatedAt int64 `json:"created_at"`
Filename string `json:"filename"`
// Purpose is batch, batch_output or job_output.
Purpose string `json:"purpose"`
Status string `json:"status"`
ExpiresAt int64 `json:"expires_at,omitempty"`
}
// FileList is the caller's files.
type FileList struct {
Object string `json:"object"`
Data []File `json:"data"`
}
// Deleted says a thing is gone.
type Deleted struct {
ID string `json:"id"`
Object string `json:"object"`
Deleted bool `json:"deleted"`
}
// BatchRequest runs a file of requests, at the lowest priority.
type BatchRequest struct {
InputFileID string `json:"input_file_id"`
// Endpoint is /v1/chat/completions, /v1/completions or /v1/embeddings.
Endpoint string `json:"endpoint"`
// CompletionWindow is 24h.
CompletionWindow string `json:"completion_window"`
Metadata map[string]string `json:"metadata,omitempty"`
}
// RequestCounts is how a batch's lines went.
type RequestCounts struct {
Total int `json:"total"`
Completed int `json:"completed"`
Failed int `json:"failed"`
}
// Batch is a batch and how far it is.
type Batch struct {
ID string `json:"id"`
Object string `json:"object"`
Endpoint string `json:"endpoint"`
InputFileID string `json:"input_file_id"`
CompletionWindow string `json:"completion_window"`
Status string `json:"status"`
OutputFileID *string `json:"output_file_id"`
ErrorFileID *string `json:"error_file_id"`
CreatedAt int64 `json:"created_at"`
InProgressAt *int64 `json:"in_progress_at"`
ExpiresAt *int64 `json:"expires_at"`
FinalizingAt *int64 `json:"finalizing_at"`
CompletedAt *int64 `json:"completed_at"`
FailedAt *int64 `json:"failed_at"`
ExpiredAt *int64 `json:"expired_at"`
CancellingAt *int64 `json:"cancelling_at"`
CancelledAt *int64 `json:"cancelled_at"`
RequestCounts RequestCounts `json:"request_counts"`
Errors json.RawMessage `json:"errors"`
Metadata json.RawMessage `json:"metadata"`
}
// BatchList is the caller's batches.
type BatchList struct {
Object string `json:"object"`
Data []Batch `json:"data"`
HasMore bool `json:"has_more"`
}
// MeshRequest asks for a 3D model from a picture, of a model that makes
// them: a job.
type MeshRequest struct {
Model string `json:"model"`
// Image is the picture, base64 or a data URL: PNG, JPEG or WebP, one
// object on a plain background.
Image string `json:"image"`
Seed *int64 `json:"seed,omitempty"`
Steps int `json:"steps,omitempty"`
// Resolution is the model's grid, a multiple of 32 from 64 to 2048.
Resolution int `json:"resolution,omitempty"`
}
// JobProgress is how far along a job is.
type JobProgress struct {
// State is queued, running, done or failed.
State string `json:"state"`
// Ahead is how many wait before it, while queued.
Ahead int `json:"ahead,omitempty"`
NodeClass string `json:"node_class,omitempty"`
Step int `json:"step,omitempty"`
Steps int `json:"steps,omitempty"`
Percent int `json:"percent,omitempty"`
}
// JobUsage is what a finished job used.
type JobUsage struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
Images int64 `json:"images"`
Meshes int64 `json:"meshes"`
}
// Job is a 3D model asked for: its status, progress and, when done, the
// file its result is in.
type Job struct {
ID string `json:"id"`
Object string `json:"object"`
Endpoint string `json:"endpoint"`
Model string `json:"model"`
// Status is queued, running, cancelling, completed, failed, cancelled
// or expired.
Status string `json:"status"`
Progress *JobProgress `json:"progress"`
OutputFileID *string `json:"output_file_id"`
ContentType *string `json:"content_type"`
Error *ErrorBody `json:"error"`
CreatedAt int64 `json:"created_at"`
StartedAt *int64 `json:"started_at"`
FinishedAt *int64 `json:"finished_at"`
ExpiresAt *int64 `json:"expires_at"`
Usage *JobUsage `json:"usage"`
License *License `json:"license,omitempty"`
}
// JobList is the caller's jobs.
type JobList struct {
Object string `json:"object"`
Data []Job `json:"data"`
HasMore bool `json:"has_more"`
FirstID *string `json:"first_id"`
LastID *string `json:"last_id"`
}
// MemoryWrite is a note kept for the caller's project.
type MemoryWrite struct {
Text string `json:"text"`
Tags []string `json:"tags,omitempty"`
}
// MemoryNote is a note kept.
type MemoryNote struct {
Key string `json:"key"`
Text string `json:"text"`
Tags []string `json:"tags"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// MemoryList is the project's notes.
type MemoryList struct {
Object string `json:"object"`
Data []MemoryNote `json:"data"`
}
// Me is the caller as the gateway sees it: its key, place and limits.
type Me struct {
Authenticated bool `json:"authenticated"`
KeyID string `json:"key_id,omitempty"`
Key string `json:"key,omitempty"`
Project string `json:"project,omitempty"`
Team string `json:"team,omitempty"`
Organization string `json:"organization,omitempty"`
// Limits and Usage are what applies and what has been used of it.
Limits json.RawMessage `json:"limits,omitempty"`
Usage json.RawMessage `json:"usage,omitempty"`
}
// MCPRequest is a JSON-RPC 2.0 call to the gateway's MCP server: its tools
// are the memory notes.
type MCPRequest struct {
JSONRPC string `json:"jsonrpc"`
ID json.RawMessage `json:"id"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// KBDeleted says a document left a collection.
type KBDeleted struct {
Name string `json:"name"`
Deleted bool `json:"deleted"`
}
+123
View File
@@ -0,0 +1,123 @@
package clientapi
import (
"encoding/json"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
"gitea.mynodes.xyz/ilker/llmgw/internal/router"
)
type (
// Route and Param are the admin API's, which one generator reads.
Route = adminapi.Route
Param = adminapi.Param
)
// PathParams describes the parameters in Routes' paths.
var PathParams = map[string]string{
"files/{id}": "The file's ID, file-….",
"batches/{id}": "The batch's ID, batch_….",
"generations/{id}": "The job's ID, job_….",
"runs/{id}": "The job's ID, job_….",
"threads/{id}": "The conversation's ID, as sent in x-llmgw-thread.",
"memory/{key}": "The note's key, chosen by the caller.",
"collections/{id}": "The collection's ID, its name when that is unique, or ORG/NAME.",
}
// any is a body whose shape is the engine's or the provider's own.
var anyJSON = json.RawMessage{}
// Routes is every endpoint the client API serves, but /health, /ready and
// /leader, which are a load balancer's and need no key.
var Routes = []Route{
{Method: "POST", Path: "/v1/chat/completions", Summary: "A chat turn",
Description: "A model's name, an alias, or an adapter's name. Fields not listed go to the engine as sent. " +
"With a knowledge base, x-llmgw-knowledge names the collections to answer from; x-llmgw-thread continues a stored conversation.",
Request: ChatCompletionRequest{}, Response: ChatCompletion{}, Stream: true},
{Method: "POST", Path: "/v1/completions", Summary: "A prompt continued",
Request: CompletionRequest{}, Response: Completion{}, Stream: true},
{Method: "POST", Path: "/v1/embeddings", Summary: "Vectors for texts",
Request: EmbeddingRequest{}, Response: EmbeddingList{}},
{Method: "POST", Path: "/v1/rerank", Summary: "Documents ordered by a query, for a reranker vLLM serves",
Request: RerankRequest{}, Response: anyJSON},
{Method: "POST", Path: "/v1/score", Summary: "Pairs of texts scored, for a scorer vLLM serves",
Request: ScoreRequest{}, Response: anyJSON},
{Method: "POST", Path: "/v1/audio/transcriptions", Summary: "Speech to text",
Request: TranscriptionForm{}, Form: true, Response: Transcription{}},
{Method: "POST", Path: "/v1/audio/translations", Summary: "Speech to English text",
Request: TranscriptionForm{}, Form: true, Response: Transcription{}},
{Method: "POST", Path: "/v1/images/generations", Summary: "Pictures, of a model that makes them",
Description: "Made by ComfyUI. Pictures come back as b64_json and are kept nowhere; x-llmgw-seed says the seed.",
Request: ImageRequest{}, Response: ImagesResponse{}},
{Method: "POST", Path: "/v1/3d/generations", Summary: "A 3D model from a picture, as a job",
Description: "Answered 202 with the job; ask after it until it is completed, then download output_file_id from /v1/files.",
Request: MeshRequest{}, Response: Job{}, Status: 202},
{Method: "GET", Path: "/v1/3d/generations", Summary: "The caller's 3D jobs, newest first",
Query: []Param{
{Name: "limit", Type: "integer", Description: "The most jobs to answer with, at most 100; 20 when absent."},
{Name: "after", Description: "A job's ID: those asked for before it."},
},
Response: JobList{}},
{Method: "GET", Path: "/v1/3d/generations/{id}", Summary: "A 3D job: its status, progress and result", Response: Job{}},
{Method: "POST", Path: "/v1/3d/generations/{id}/cancel", Summary: "Stop a 3D job, in the engine's queue too", Response: Job{}},
{Method: "POST", Path: "/v1/workflows/runs", Summary: "Run a ComfyUI model's workflow, as a job",
Description: "Inputs are those the model's workflow lists in /v1/models. Answered 202 with the job; " +
"download output_file_id from /v1/files once it is completed.",
Request: RunRequest{}, Response: Job{}, Status: 202},
{Method: "GET", Path: "/v1/workflows/runs", Summary: "The caller's jobs, newest first",
Query: []Param{
{Name: "limit", Type: "integer", Description: "The most jobs to answer with, at most 100; 20 when absent."},
{Name: "after", Description: "A job's ID: those asked for before it."},
},
Response: JobList{}},
{Method: "GET", Path: "/v1/workflows/runs/{id}", Summary: "A run: its status, progress and result", Response: Job{}},
{Method: "POST", Path: "/v1/workflows/runs/{id}/cancel", Summary: "Stop a run, in the engine's queue too", Response: Job{}},
{Method: "POST", Path: "/v1/moderations", Summary: "Texts judged by the guardrails' judge model",
Request: ModerationRequest{}, Response: ModerationResponse{}},
{Method: "POST", Path: "/v1/responses", Summary: "OpenAI's Responses API, for Codex and the like",
Request: ResponsesRequest{}, Response: anyJSON, Stream: true},
{Method: "POST", Path: "/v1/messages", Summary: "Anthropic's Messages API, for Claude Code and Anthropic's SDKs",
Description: "The key is sent as x-api-key or as a bearer token.",
Request: MessagesRequest{}, Response: anyJSON, Stream: true},
{Method: "POST", Path: "/v1/messages/count_tokens", Summary: "How many tokens a Messages request would be",
Request: MessagesRequest{}, Response: TokenCount{}},
{Method: "GET", Path: "/v1/models", Summary: "The models the key may use", Response: ModelList{}},
{Method: "POST", Path: "/v1/files", Summary: "Upload a file: a batch's input",
Request: FileForm{}, Form: true, Response: File{}},
{Method: "GET", Path: "/v1/files", Summary: "The caller's files",
Query: []Param{{Name: "purpose", Description: "Only files of this purpose: batch, batch_output, job_output."}},
Response: FileList{}},
{Method: "GET", Path: "/v1/files/{id}", Summary: "A file", Response: File{}},
{Method: "GET", Path: "/v1/files/{id}/content", Summary: "A file's content",
Binary: "application/octet-stream"},
{Method: "DELETE", Path: "/v1/files/{id}", Summary: "Delete a file", Response: Deleted{}},
{Method: "POST", Path: "/v1/batches", Summary: "Run a file of requests at the lowest priority",
Request: BatchRequest{}, Response: Batch{}},
{Method: "GET", Path: "/v1/batches", Summary: "The caller's batches",
Query: []Param{
{Name: "limit", Type: "integer", Description: "The most batches to answer with; 20 when absent."},
{Name: "after", Description: "A batch's ID: those made before it."},
},
Response: BatchList{}},
{Method: "GET", Path: "/v1/batches/{id}", Summary: "A batch and how far it is", Response: Batch{}},
{Method: "POST", Path: "/v1/batches/{id}/cancel", Summary: "Stop a batch", Response: Batch{}},
{Method: "DELETE", Path: "/v1/threads/{id}", Summary: "Forget a stored conversation"},
{Method: "PUT", Path: "/v1/memory/{key}", Summary: "Keep a note for the project",
Request: MemoryWrite{}, Response: MemoryNote{}},
{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, 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{}},
{Method: "GET", Path: "/v1/kb/collections", Summary: "The knowledge-base collections the caller may read",
Response: []router.KBCollectionView{}},
{Method: "GET", Path: "/v1/kb/collections/{id}/manifest", Summary: "A collection and its documents",
Response: router.KBManifest{}},
{Method: "PUT", Path: "/v1/kb/collections/{id}/documents", Summary: "Add or replace a document, read and embedded by the client",
Request: router.KBPutDocument{}, Response: router.KBPutResult{}},
{Method: "DELETE", Path: "/v1/kb/collections/{id}/documents", Summary: "Take a document out of a collection",
Query: []Param{{Name: "name", Required: true, Description: "The document's name, as the manifest lists it."}},
Response: KBDeleted{}},
}
+77
View File
@@ -0,0 +1,77 @@
package clientapi
import (
"os"
"path/filepath"
"regexp"
"runtime"
"slices"
"strings"
"testing"
)
// routerRoutes are the client API's routes as the router mounts them, read
// from its source: every mux.HandleFunc under /v1, /mcp and /me, with the
// path constants it builds some of them from put back.
func routerRoutes(t *testing.T) []string {
t.Helper()
_, file, _, _ := runtime.Caller(0)
dir := filepath.Join(filepath.Dir(file), "..", "router")
consts := map[string]string{"imagesPath": "/v1/images/generations", "meshesPath": "/v1/3d/generations",
"workflowsPath": "/v1/workflows/runs"}
literal := regexp.MustCompile(`mux\.HandleFunc\("([A-Z]+) (/[^"]*)"`)
built := regexp.MustCompile(`mux\.HandleFunc\("([A-Z]+) "\+([A-Za-z]+Path)(\+"([^"]*)")?`)
files, _ := filepath.Glob(filepath.Join(dir, "*.go"))
var out []string
add := func(method, path string) {
if strings.HasPrefix(path, "/v1/") || path == "/mcp" || path == "/me" {
out = append(out, method+" "+path)
}
}
for _, f := range files {
if strings.HasSuffix(f, "_test.go") {
continue
}
b, _ := os.ReadFile(f)
for _, m := range literal.FindAllStringSubmatch(string(b), -1) {
add(m[1], m[2])
}
for _, m := range built.FindAllStringSubmatch(string(b), -1) {
c, ok := consts[m[2]]
if !ok {
t.Fatalf("a route built from %s, which this test does not know", m[2])
}
add(m[1], c+m[4])
}
}
slices.Sort(out)
return slices.Compact(out)
}
func TestEveryClientRouteIsDescribedAndNothingElse(t *testing.T) {
// An endpoint added to the router and not to this table would be missing
// from the document every SDK is generated from; one here and not in the
// router would be a path that answers 404.
mounted := routerRoutes(t)
if len(mounted) < 30 {
t.Fatalf("read only %d routes from the router: %v", len(mounted), mounted)
}
var described []string
for _, r := range Routes {
described = append(described, r.Method+" "+r.Path)
}
slices.Sort(described)
for _, m := range mounted {
if m == "GET /v1/openapi.json" {
continue // the document itself
}
if !slices.Contains(described, m) {
t.Errorf("%s is mounted and not described in clientapi.Routes", m)
}
}
for _, d := range described {
if !slices.Contains(mounted, d) {
t.Errorf("%s is described and not mounted", d)
}
}
}
+286 -26
View File
@@ -21,8 +21,10 @@ import (
"fmt"
"io"
"math/big"
"mime/multipart"
"net/http"
"net/url"
"path"
"slices"
"strconv"
"strings"
@@ -98,9 +100,19 @@ type Params struct {
Seed int64
Steps int
Guidance float64
// Image is the input picture, by the name Upload gave it in ComfyUI's
// input directory: {{image}}.
Image string
// Extra are a workflow's own placeholders, by name: {{resolution}}.
Extra map[string]any
}
func (p Params) value(name string) (any, bool) {
// A workflow's own input of a built-in's name is the request's: an input
// named prompt is the prompt a run was given.
if v, ok := p.Extra[name]; ok {
return v, true
}
switch name {
case "checkpoint":
return p.Checkpoint, p.Checkpoint != ""
@@ -120,10 +132,49 @@ func (p Params) value(name string) (any, bool) {
return p.Steps, p.Steps > 0
case "guidance":
return p.Guidance, p.Guidance > 0
case "image":
return p.Image, p.Image != ""
}
return nil, false
}
// Builtin are the placeholders Params fills by itself, which a workflow may
// use without declaring them as inputs.
var Builtin = []string{"checkpoint", "prompt", "negative_prompt", "width", "height", "n", "seed", "steps", "guidance", "image"}
// Placeholders are the names a template's placeholders use, each once,
// sorted, and whether each has a default of its own.
func (t *Template) Placeholders() map[string]bool {
out := map[string]bool{}
var walk func(v any)
walk = func(v any) {
switch v := v.(type) {
case map[string]any:
for _, x := range v {
walk(x)
}
case []any:
for _, x := range v {
walk(x)
}
case string:
for s := v; ; {
i := strings.Index(s, "{{")
j := strings.Index(s, "}}")
if i < 0 || j < i {
return
}
name, _, hasDef := strings.Cut(s[i+2:j], "|")
name = strings.TrimSpace(name)
out[name] = out[name] || hasDef
s = s[j+2:]
}
}
}
walk(t.graph)
return out
}
// Fill is the workflow for one request.
func (t *Template) Fill(p Params) (map[string]any, error) {
var missing []string
@@ -171,6 +222,10 @@ func fillString(s string, p Params, missing *[]string) any {
if f, err := strconv.ParseFloat(def, 64); err == nil {
return f, true
}
// A BOOLEAN input refuses the word: {{albedo_match|false}}.
if b, err := strconv.ParseBool(def); err == nil && (def == "true" || def == "false") {
return b, true
}
return def, true
}
*missing = append(*missing, name)
@@ -207,10 +262,12 @@ func RandomSeed() int64 {
return n.Int64()
}
// Image is one picture made.
// Image is one file a workflow made: a picture, a mesh.
type Image struct {
Data []byte
ContentType string
// Name is the file's own, as ComfyUI saved it.
Name string
}
// Error is a failure, said as what it means for the request.
@@ -235,6 +292,17 @@ type Client struct {
// workflow out of the queue, or interrupts it if it is running, so a client
// that went away does not leave the card working for nobody.
func (c *Client) Generate(ctx context.Context, base, apiKey string, workflow map[string]any) ([]Image, error) {
return c.GenerateWatch(ctx, base, apiKey, workflow, nil)
}
// progressEvery is how often a workflow's progress is asked after, beside
// its history: a few requests a second is for the end, not for the news.
const progressEvery = time.Second
// GenerateWatch is Generate, saying how far along the workflow is as it
// changes: queued and how many ahead, then the node running and its step.
func (c *Client) GenerateWatch(ctx context.Context, base, apiKey string, workflow map[string]any,
onProgress func(Progress)) ([]Image, error) {
base = strings.TrimRight(base, "/")
client := make([]byte, 8)
_, _ = rand.Read(client)
@@ -265,6 +333,8 @@ func (c *Client) Generate(ctx context.Context, base, apiKey string, workflow map
}
t := time.NewTicker(poll)
defer t.Stop()
var last Progress
var asked time.Time
for {
select {
case <-ctx.Done():
@@ -272,6 +342,13 @@ func (c *Client) Generate(ctx context.Context, base, apiKey string, workflow map
return nil, ctx.Err()
case <-t.C:
}
if onProgress != nil && time.Since(asked) >= progressEvery {
asked = time.Now()
if p, ok := c.progress(ctx, base, apiKey, id, workflow); ok && p != last {
last = p
onProgress(p)
}
}
var hist map[string]historyEntry
if _, err := c.do(ctx, http.MethodGet, base+"/history/"+url.PathEscape(id), apiKey, nil, &hist); err != nil {
if ctx.Err() != nil {
@@ -299,13 +376,16 @@ type historyEntry struct {
Completed bool `json:"completed"`
Messages [][]json.RawMessage `json:"messages"`
} `json:"status"`
Outputs map[string]struct {
Images []struct {
Filename string `json:"filename"`
Subfolder string `json:"subfolder"`
Type string `json:"type"`
} `json:"images"`
} `json:"outputs"`
// Outputs are each node's, by kind: "images" for a picture, "3d" for
// the mesh a 3D node saves, and whatever else a custom node names.
Outputs map[string]map[string]json.RawMessage `json:"outputs"`
}
// fileRef is a file an output names, to be read from /view.
type fileRef struct {
Filename string `json:"filename"`
Subfolder string `json:"subfolder"`
Type string `json:"type"`
}
// failure is what ComfyUI said went wrong, from its execution_error.
@@ -341,17 +421,46 @@ func (c *Client) fetch(ctx context.Context, base, apiKey string, h historyEntry)
})
var out []Image
for _, n := range nodes {
for _, im := range h.Outputs[n].Images {
q := url.Values{"filename": {im.Filename}, "subfolder": {im.Subfolder}, "type": {im.Type}}
img, err := c.get(ctx, base+"/view?"+q.Encode(), apiKey)
if err != nil {
return nil, err
kinds := make([]string, 0, len(h.Outputs[n]))
for k := range h.Outputs[n] {
kinds = append(kinds, k)
}
slices.Sort(kinds)
for _, k := range kinds {
// Any list of files, whatever the node calls it; text and
// numbers a node reports beside them are not files.
var refs []fileRef
if json.Unmarshal(h.Outputs[n][k], &refs) != nil {
continue
}
for _, f := range refs {
if f.Filename == "" {
continue
}
q := url.Values{"filename": {f.Filename}, "subfolder": {f.Subfolder}, "type": {f.Type}}
img, err := c.get(ctx, base+"/view?"+q.Encode(), apiKey)
if err != nil {
return nil, err
}
img.Name = f.Filename
// A type the server did not say is the file's by its name,
// else by its first bytes.
if generic(img.ContentType) {
if t := typeByName(f.Filename); t != "" {
img.ContentType = t
} else {
img.ContentType = http.DetectContentType(img.Data)
}
}
if img.ContentType == "image/png" {
img.Data = withoutPNGText(img.Data)
}
out = append(out, img)
}
out = append(out, img)
}
}
if len(out) == 0 {
return nil, &Error{Msg: "the workflow finished without a picture: it has no SaveImage or PreviewImage node"}
return nil, &Error{Msg: "the workflow finished without a file: it has no node that saves one"}
}
return out, nil
}
@@ -434,21 +543,19 @@ func (c *Client) get(ctx context.Context, u, apiKey string) (Image, error) {
return Image{}, err
}
defer resp.Body.Close()
b, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
b, err := io.ReadAll(io.LimitReader(resp.Body, maxFile))
if err != nil {
return Image{}, &Error{Msg: "reading a picture from ComfyUI: " + err.Error(), Retryable: true}
return Image{}, &Error{Msg: "reading a file from ComfyUI: " + err.Error(), Retryable: true}
}
if resp.StatusCode != http.StatusOK {
return Image{}, &Error{Msg: fmt.Sprintf("ComfyUI answered %d for a picture it made", resp.StatusCode)}
return Image{}, &Error{Msg: fmt.Sprintf("ComfyUI answered %d for a file it made", resp.StatusCode)}
}
ct := resp.Header.Get("Content-Type")
if ct == "" || ct == "application/octet-stream" {
ct = http.DetectContentType(b)
}
if ct == "image/png" {
b = withoutPNGText(b)
}
return Image{Data: b, ContentType: ct}, nil
return Image{Data: b, ContentType: resp.Header.Get("Content-Type")}, nil
}
// generic is a type that says nothing of what a file is.
func generic(ct string) bool {
return ct == "" || strings.HasPrefix(ct, "application/octet-stream") || strings.HasPrefix(ct, "text/plain")
}
// withoutPNGText is a PNG without its text chunks. ComfyUI writes the whole
@@ -527,3 +634,156 @@ func snippet(b []byte) string {
}
return s
}
// maxFile bounds one file read from ComfyUI: a mesh with its textures runs
// to tens of megabytes, a picture to a few.
const maxFile = 256 << 20
// typeByName is a file's type by its extension, for those a server sends
// as octet-stream: the meshes a 3D node saves.
func typeByName(name string) string {
switch strings.ToLower(path.Ext(name)) {
case ".glb":
return "model/gltf-binary"
case ".gltf":
return "model/gltf+json"
case ".obj":
return "model/obj"
case ".stl":
return "model/stl"
case ".ply":
return "application/ply"
case ".png":
return "image/png"
case ".jpg", ".jpeg":
return "image/jpeg"
case ".webp":
return "image/webp"
case ".gif":
return "image/gif"
case ".mp4":
return "video/mp4"
case ".webm":
return "video/webm"
case ".mov":
return "video/quicktime"
case ".wav":
return "audio/wav"
case ".mp3":
return "audio/mpeg"
case ".flac":
return "audio/flac"
case ".ogg", ".opus":
return "audio/ogg"
}
return ""
}
// Upload puts a file in ComfyUI's input directory, where a loader node reads
// it — LoadImage, LoadAudio, LoadVideo, whatever the workflow uses: ComfyUI's
// /upload/image takes any file — and returns the name it was given there.
func (c *Client) Upload(ctx context.Context, base, apiKey, name string, data []byte) (string, error) {
base = strings.TrimRight(base, "/")
var body bytes.Buffer
mw := multipart.NewWriter(&body)
fw, err := mw.CreateFormFile("image", name)
if err != nil {
return "", err
}
if _, err := fw.Write(data); err != nil {
return "", err
}
_ = mw.WriteField("type", "input")
_ = mw.WriteField("overwrite", "true")
if err := mw.Close(); err != nil {
return "", err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/upload/image", &body)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", mw.FormDataContentType())
if apiKey != "" {
req.Header.Set("Authorization", "Bearer "+apiKey)
}
resp, err := c.client().Do(req)
if err != nil {
if ctx.Err() != nil {
return "", ctx.Err()
}
return "", &Error{Msg: "ComfyUI could not be reached: " + err.Error(), Retryable: true}
}
defer resp.Body.Close()
b, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode >= 500 {
return "", &Error{Msg: fmt.Sprintf("ComfyUI answered %d to the picture: %s", resp.StatusCode, snippet(b)), Retryable: true}
}
var out struct {
Name string `json:"name"`
Subfolder string `json:"subfolder"`
}
if resp.StatusCode != http.StatusOK || json.Unmarshal(b, &out) != nil || out.Name == "" {
return "", &Error{Msg: fmt.Sprintf("ComfyUI did not take the picture (%d): %s", resp.StatusCode, snippet(b))}
}
if out.Subfolder != "" {
return out.Subfolder + "/" + out.Name, nil
}
return out.Name, nil
}
// progress is a workflow's: its place in the queue, from ComfyUI, and the
// node running and its step, from the gate when there is one — a ComfyUI
// outside the cluster has no gate, and says only where it is queued.
func (c *Client) progress(ctx context.Context, base, apiKey, id string, workflow map[string]any) (Progress, bool) {
p := Progress{PromptID: id, State: "queued"}
var q struct {
Running [][]json.RawMessage `json:"queue_running"`
Pending [][]json.RawMessage `json:"queue_pending"`
}
if _, err := c.do(ctx, http.MethodGet, base+"/queue", apiKey, nil, &q); err != nil {
return p, false
}
quoted := strconv.Quote(id)
queued := false
for _, r := range q.Running {
if len(r) > 1 && string(r[1]) == quoted {
p.State = "running"
}
}
if p.State != "running" {
// Pending in the order ComfyUI will take them: by number.
mine := -1.0
var numbers []float64
for _, r := range q.Pending {
if len(r) < 2 {
continue
}
var n float64
_ = json.Unmarshal(r[0], &n)
if string(r[1]) == quoted {
mine, queued = n, true
continue
}
numbers = append(numbers, n)
}
if queued {
p.Ahead = len(q.Running)
for _, n := range numbers {
if n < mine {
p.Ahead++
}
}
return p, true
}
}
var g Progress
if st, err := c.do(ctx, http.MethodGet, base+"/progress/"+url.PathEscape(id), apiKey, nil, &g); err == nil &&
st == http.StatusOK && g.State != "" {
p = g
p.PromptID = id
if node, ok := workflow[p.Node].(map[string]any); ok {
p.NodeClass, _ = node["class_type"].(string)
}
}
return p, true
}
+43
View File
@@ -15,6 +15,9 @@ import (
func TestTheBuiltInWorkflowsFillIn(t *testing.T) {
for _, name := range Builtins() {
if raw, _ := builtin.ReadFile("workflows/" + name + ".json"); strings.Contains(string(raw), "{{image}}") {
continue // made from a picture: TestTheBuiltInMeshWorkflowFillsIn
}
tpl, err := Load(name)
if err != nil {
t.Fatalf("%s: %v", name, err)
@@ -37,6 +40,30 @@ func TestTheBuiltInWorkflowsFillIn(t *testing.T) {
}
}
func TestTheBuiltInMeshWorkflowFillsIn(t *testing.T) {
tpl, err := Load("triposr")
if err != nil {
t.Fatal(err)
}
g, err := tpl.Fill(Params{Checkpoint: "model.ckpt", Image: "llmgw-1.png"})
if err != nil {
t.Fatal(err)
}
b, _ := json.Marshal(g)
s := string(b)
// The picture is cut out and centred before TripoSR sees it: without
// that, its background becomes geometry.
for _, want := range []string{`"image":"llmgw-1.png"`, `"model":"model.ckpt"`, `"geometry_resolution":256`,
`"class_type":"LLMGWPrepareForMesh"`, `"reference_image":["5",0]`} {
if !strings.Contains(s, want) {
t.Errorf("%s not in %s", want, s)
}
}
if strings.Contains(s, "{{") {
t.Errorf("a placeholder was left: %s", s)
}
}
func TestAStepCountGivenReplacesTheDefault(t *testing.T) {
tpl, _ := Load("flux-schnell")
g, _ := tpl.Fill(Params{Checkpoint: "c", Prompt: "p", Width: 512, Height: 512})
@@ -225,3 +252,19 @@ func TestAPictureLosesTheWorkflowComfyUIWroteIntoIt(t *testing.T) {
t.Error("a truncated PNG was changed")
}
}
func TestATrueOrFalseDefaultIsABoolean(t *testing.T) {
// A BOOLEAN input refuses the word "false": sf3d's albedo_match.
tpl, err := Load((`{"5":{"class_type":"LLMGWSaveMesh","inputs":{"albedo_match":"{{albedo_match|false}}","count":"{{count|2}}","s":"{{s|x}}"}}}`))
if err != nil {
t.Fatal(err)
}
g, err := tpl.Fill(Params{})
if err != nil {
t.Fatal(err)
}
in := g["5"].(map[string]any)["inputs"].(map[string]any)
if in["albedo_match"] != false || in["count"] != 2.0 || in["s"] != "x" {
t.Errorf("inputs %#v", in)
}
}
+586
View File
@@ -0,0 +1,586 @@
package comfy
import (
"context"
"crypto/rand"
"crypto/sha1"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/http/httputil"
"net/url"
"os"
"os/exec"
"os/signal"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"syscall"
"time"
)
// The gate is how a node runs ComfyUI as one of llmgw's engines.
//
// ComfyUI asks nobody for a key: whoever reaches its port can queue work,
// upload files and install nodes. Every other engine here demands the key
// the control plane made for its replica, so ComfyUI is put behind a gate
// that does: ComfyUI listens on the loopback only, and the gate on the
// replica's port, letting through only the requests the router makes. The
// gate also answers what the agent asks every engine — /health once
// ComfyUI is up, and /v1/models naming the model — and lays the weights out
// where ComfyUI looks for them.
//
// ComfyUI runs as the gate's child, in its process group, so stopping the
// replica stops both, the card memory ComfyUI holds is counted as the
// replica's, and either one exiting ends the other.
// GateConfig is one replica's gate.
type GateConfig struct {
// Listen is the replica's address, the one the router is given.
Listen string
// Model is the name the agent asks /v1/models for.
Model string
// Key is what every request must carry as a bearer token; empty lets
// every request through, as an engine started without one does.
Key string
// Weights is the model as the agent fetched it: a file, or a directory
// of files.
Weights string
// State is the replica's own directory: ComfyUI's base directory, with
// its output, temp and user directories, and the models it is shown.
State string
// Python runs ComfyUI's main.py, found in Dir.
Python, Dir string
// Args are passed on to main.py, after the gate's own.
Args []string
// Output is where ComfyUI's output goes.
Output io.Writer
Log *slog.Logger
}
// The requests the router makes of ComfyUI: queueing, waiting, fetching and
// cancelling. Nothing that uploads, installs or changes ComfyUI.
var gatePaths = []string{"/prompt", "/history/", "/view", "/queue", "/interrupt", "/system_stats", "/object_info",
// The input picture of a 3D model: into ComfyUI's input directory,
// which is the replica's own.
"/upload/image"}
// modelFolders are the kinds of file ComfyUI's loaders look for. Each is
// shown the same directory: a workflow names its files, so a file being
// listed where it is not wanted costs nothing.
var modelFolders = []string{"checkpoints", "diffusion_models", "text_encoders", "vae", "loras",
"clip_vision", "controlnet", "upscale_models", "embeddings", "style_models"}
// RunGate lays out the replica, starts ComfyUI and serves the gate until
// ctx ends or ComfyUI exits.
func RunGate(ctx context.Context, cfg GateConfig) error {
log := cfg.Log
if log == nil {
log = slog.Default()
}
state, err := filepath.Abs(cfg.State)
if err != nil {
return err
}
// What ComfyUI expects to find in its base directory, and dies
// without: custom_nodes is listed before anything else is done.
for _, d := range []string{"custom_nodes", "input", "output", "temp", "user"} {
if err := os.MkdirAll(filepath.Join(state, d), 0o755); err != nil {
return err
}
}
out := cfg.Output
if out == nil {
out = os.Stdout
}
if err := installOwnNodes(state); err != nil {
return fmt.Errorf("llmgw's own ComfyUI nodes: %w", err)
}
nodes, err := parseNodes(os.Getenv(NodesEnv))
if err != nil {
return err
}
site, err := installNodes(ctx, state, cfg.Python, nodes, findUV(), func(s string) { fmt.Fprintln(out, "[gate] "+s) })
if err != nil {
return err
}
models := filepath.Join(state, "models")
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 != "" {
if err := LinkHFCache(cfg.Weights, repo, hfHubDir()); err != nil {
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
}
inner, err := freePort()
if err != nil {
return err
}
args := append([]string{filepath.Join(cfg.Dir, "main.py"),
"--listen", "127.0.0.1", "--port", strconv.Itoa(inner),
"--base-directory", state, "--extra-model-paths-config", extra,
"--disable-auto-launch"}, cfg.Args...)
child := exec.Command(cfg.Python, args...) //nolint:gosec // the agent's own configuration
child.Dir = cfg.Dir
child.Env = os.Environ()
if site != "" {
pp := site
if old := os.Getenv("PYTHONPATH"); old != "" {
pp += string(os.PathListSeparator) + old
}
child.Env = append(child.Env, "PYTHONPATH="+pp)
}
// ComfyUI writes to a pipe of the gate's, never to what the gate was
// given. That is the agent's, and an agent that restarts and adopts the
// replica leaves it with no reader: ComfyUI's next progress bar would
// fail with a broken pipe, and take the workflow it belongs to with it.
// The gate reads the pipe for as long as ComfyUI writes, and passes on
// what it can.
signal.Ignore(syscall.SIGPIPE)
pr, err := child.StdoutPipe()
if err != nil {
return err
}
child.Stderr = child.Stdout
if err := child.Start(); err != nil {
return fmt.Errorf("starting ComfyUI: %w", err)
}
relayed := make(chan struct{})
go func() { relay(pr, out); close(relayed) }()
exited := make(chan error, 1)
go func() {
// Wait closes the pipe; what is in it is read first.
<-relayed
exited <- child.Wait()
}()
upstream := &url.URL{Scheme: "http", Host: net.JoinHostPort("127.0.0.1", strconv.Itoa(inner))}
// The workflows' progress, heard on ComfyUI's websocket under an id of
// the gate's own.
book := newProgressBook()
cid := make([]byte, 8)
_, _ = rand.Read(cid)
clientID := "llmgw-gate-" + hex.EncodeToString(cid)
go book.watch(ctx, upstream, clientID, log)
srv := &http.Server{
Addr: cfg.Listen,
Handler: newGate(upstream, cfg.Key, names, book, clientID),
ReadHeaderTimeout: 10 * time.Second,
}
served := make(chan error, 1)
go func() { served <- srv.ListenAndServe() }()
log.Info("ComfyUI gate up", "listen", cfg.Listen, "comfyui", upstream.Host, "weights", cfg.Weights)
stop := func() {
_ = child.Process.Signal(syscall.SIGTERM)
select {
case <-exited:
case <-time.After(20 * time.Second):
_ = child.Process.Kill()
<-exited
}
}
select {
case <-ctx.Done():
shutdown(srv)
stop()
return nil
case err := <-exited:
shutdown(srv)
if err == nil {
err = errors.New("exited")
}
return fmt.Errorf("ComfyUI %w", err)
case err := <-served:
stop()
return fmt.Errorf("the gate stopped serving: %w", err)
}
}
func shutdown(srv *http.Server) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
}
// 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, []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 string, names []string, book *progressBook, clientID string) http.Handler {
proxy := httputil.NewSingleHostReverseProxy(upstream)
base := proxy.Director
proxy.Director = func(r *http.Request) {
base(r)
r.Header.Del("Authorization")
// ComfyUI refuses a request whose Origin is not its own host; the
// router sends none, and nothing else gets this far.
r.Header.Del("Origin")
r.Host = upstream.Host
}
client := &http.Client{Timeout: 3 * time.Second}
up := func(ctx context.Context) error {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, upstream.String()+"/system_stats", nil)
resp, err := client.Do(req)
if err != nil {
return err
}
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("ComfyUI answered %s", resp.Status)
}
return nil
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if key != "" {
got, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if !ok || subtle.ConstantTimeCompare([]byte(got), []byte(key)) != 1 {
http.Error(w, "this engine wants its key", http.StatusUnauthorized)
return
}
}
switch r.URL.Path {
case "/health":
if err := up(r.Context()); err != nil {
http.Error(w, "ComfyUI is not up yet: "+err.Error(), http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
return
case "/v1/models":
if err := up(r.Context()); err != nil {
http.Error(w, "ComfyUI is not up yet: "+err.Error(), http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json")
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 {
p, found := book.get(id)
if !found {
http.Error(w, "no word of that workflow", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(p)
return
}
if r.URL.Path == "/prompt" && r.Method == http.MethodPost && clientID != "" {
book.promptThroughGate(proxy, clientID)(w, r)
return
}
for _, p := range gatePaths {
if r.URL.Path == p || strings.HasSuffix(p, "/") && strings.HasPrefix(r.URL.Path, p) {
proxy.ServeHTTP(w, r)
return
}
}
http.NotFound(w, r)
})
}
// cachedName is a file the agent fetched by itself: its cache names it by
// the source's hash, then the name it had.
var cachedName = regexp.MustCompile(`^[0-9a-f]{16}-`)
// FileName is what a weights file is called where ComfyUI sees it: its own
// name, without the prefix the agent's cache gives it.
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.
func LinkWeights(weights, dir string) error {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
old, err := os.ReadDir(dir)
if err != nil {
return err
}
for _, e := range old {
if e.Type()&os.ModeSymlink != 0 {
_ = os.Remove(filepath.Join(dir, e.Name()))
}
}
weights, err = filepath.Abs(weights)
if err != nil {
return err
}
info, err := os.Stat(weights)
if err != nil {
return fmt.Errorf("the weights: %w", err)
}
files := []string{weights}
if info.IsDir() {
entries, err := os.ReadDir(weights)
if err != nil {
return err
}
files = files[:0]
for _, e := range entries {
if e.Type().IsRegular() && !strings.HasPrefix(e.Name(), ".") {
files = append(files, filepath.Join(weights, e.Name()))
}
}
if len(files) == 0 {
return fmt.Errorf("the weights directory %s has no files", weights)
}
}
for _, f := range files {
if err := os.Symlink(f, filepath.Join(dir, FileName(f))); err != nil {
return err
}
}
return nil
}
func extraModelPaths(models string) string {
var b strings.Builder
fmt.Fprintf(&b, "llmgw:\n base_path: %q\n is_default: true\n", models)
for _, f := range modelFolders {
fmt.Fprintf(&b, " %s: .\n", f)
}
return b.String()
}
func freePort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, err
}
defer l.Close()
return l.Addr().(*net.TCPAddr).Port, nil
}
// relay copies r to w for as long as r has anything, and goes on reading
// after w fails: a writer that stops reading must not stop the one writing.
func relay(r io.Reader, w io.Writer) {
buf := make([]byte, 32<<10)
broken := false
for {
n, err := r.Read(buf)
if n > 0 && !broken {
if _, werr := w.Write(buf[:n]); werr != nil {
broken = true
}
}
if err != nil {
return
}
}
}
// HFRepoEnv is where the agent hands the gate the repository a custom node
// loads the weights by name from.
const HFRepoEnv = "LLMGW_COMFYUI_HF_REPO"
// hfHubDir is the Hugging Face cache of the user the gate runs as.
func hfHubDir() string {
if d := os.Getenv("HF_HUB_CACHE"); d != "" {
return d
}
if d := os.Getenv("HF_HOME"); d != "" {
return filepath.Join(d, "hub")
}
home, _ := os.UserHomeDir()
return filepath.Join(home, ".cache", "huggingface", "hub")
}
// LinkHFCache shows the weights to a node that loads them by repository
// name: a snapshot of repo in the Hugging Face cache at hub, each file a link
// to the weights, refs/main naming it. huggingface_hub then answers from the
// cache, and a gated repository needs no token on the node.
func LinkHFCache(weights, repo, hub string) error {
owner, name, ok := strings.Cut(repo, "/")
if !ok || owner == "" || name == "" || strings.Contains(name, "/") || strings.Contains(repo, "..") || strings.Contains(repo, "\\") {
return fmt.Errorf("%q is not owner/name", repo)
}
weights, err := filepath.Abs(weights)
if err != nil {
return err
}
// The snapshot is named as a commit is, which is what refs/main must
// hold, and by the weights, so other weights are another snapshot.
sum := sha1.Sum([]byte(weights)) //nolint:gosec // a name, not a secret
revision := hex.EncodeToString(sum[:])
info, err := os.Stat(weights)
if err != nil {
return err
}
files := []string{weights}
if info.IsDir() {
entries, err := os.ReadDir(weights)
if err != nil {
return err
}
files = files[:0]
for _, e := range entries {
if !e.IsDir() && !strings.HasPrefix(e.Name(), ".") {
files = append(files, filepath.Join(weights, e.Name()))
}
}
}
root := filepath.Join(hub, "models--"+owner+"--"+name)
snap := filepath.Join(root, "snapshots", revision)
// Made again each time: links to weights that moved must not stay.
_ = os.RemoveAll(snap)
if err := os.MkdirAll(snap, 0o755); err != nil {
return err
}
for _, f := range files {
if err := os.Symlink(f, filepath.Join(snap, FileName(f))); err != nil {
return err
}
}
if err := os.MkdirAll(filepath.Join(root, "refs"), 0o755); err != nil {
return err
}
return os.WriteFile(filepath.Join(root, "refs", "main"), []byte(revision), 0o644) //nolint:gosec // not secret
}
+301
View File
@@ -0,0 +1,301 @@
package comfy
import (
"context"
"errors"
"io"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestTheGateWantsTheKeyAndLetsThroughOnlyTheRoutersRequests(t *testing.T) {
var got []string
comfy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = append(got, r.Method+" "+r.URL.Path+" auth="+r.Header.Get("Authorization"))
w.WriteHeader(http.StatusOK)
}))
defer comfy.Close()
u, _ := url.Parse(comfy.URL)
gate := httptest.NewServer(NewGate(u, "k", "flux"))
defer gate.Close()
do := func(method, path, key string) (int, string) {
req, _ := http.NewRequest(method, gate.URL+path, strings.NewReader("{}"))
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(b)
}
if code, _ := do("POST", "/prompt", ""); code != http.StatusUnauthorized {
t.Errorf("no key: %d", code)
}
if code, _ := do("POST", "/prompt", "wrong"); code != http.StatusUnauthorized {
t.Errorf("wrong key: %d", code)
}
for _, p := range []string{"/upload/mask", "/api/manager/queue/install", "/userdata/x", "/internal/logs"} {
if code, _ := do("POST", p, "k"); code != http.StatusNotFound {
t.Errorf("%s: %d, want it refused", p, code)
}
}
for _, p := range []string{"/prompt", "/history/p1", "/view?filename=a.png", "/queue", "/interrupt", "/upload/image"} {
if code, _ := do("POST", p, "k"); code != http.StatusOK {
t.Errorf("%s: %d", p, code)
}
}
if code, body := do("GET", "/v1/models", "k"); code != 200 || !strings.Contains(body, `"id":"flux"`) {
t.Errorf("/v1/models: %d %s", code, body)
}
if code, _ := do("GET", "/health", "k"); code != 200 {
t.Errorf("/health: %d", code)
}
for _, g := range got {
if strings.Contains(g, "auth=Bearer") {
t.Errorf("the key reached ComfyUI: %s", g)
}
}
comfy.Close()
if code, _ := do("GET", "/health", "k"); code != http.StatusServiceUnavailable {
t.Errorf("/health with ComfyUI down: %d", code)
}
}
func TestTheWeightsAreShownByTheirOwnNames(t *testing.T) {
cache := t.TempDir()
one := filepath.Join(cache, "0123456789abcdef-flux1-schnell-fp8.safetensors")
_ = os.WriteFile(one, []byte("w"), 0o644)
models := filepath.Join(t.TempDir(), "models")
if err := LinkWeights(one, models); err != nil {
t.Fatal(err)
}
if target, err := os.Readlink(filepath.Join(models, "flux1-schnell-fp8.safetensors")); err != nil || target != one {
t.Fatalf("%q %v", target, err)
}
set := t.TempDir()
for _, n := range []string{"flux1-dev.safetensors", "ae.safetensors", "t5xxl_fp8.safetensors", ".complete"} {
_ = os.WriteFile(filepath.Join(set, n), []byte("w"), 0o644)
}
if err := LinkWeights(set, models); err != nil {
t.Fatal(err)
}
entries, _ := os.ReadDir(models)
var names []string
for _, e := range entries {
names = append(names, e.Name())
}
if strings.Join(names, ",") != "ae.safetensors,flux1-dev.safetensors,t5xxl_fp8.safetensors" {
t.Errorf("shown %v: the old weights' link should be gone and the marker not shown", names)
}
if !strings.Contains(extraModelPaths(models), "checkpoints: .") {
t.Error(extraModelPaths(models))
}
}
// TestMain lets the test binary stand in for ComfyUI: started by the gate
// as `python main.py --listen … --port …`, it answers /system_stats and
// says what it was given.
func TestMain(m *testing.M) {
if os.Getenv("LLMGW_FAKE_COMFYUI") == "1" {
fakeComfyUI()
return
}
os.Exit(m.Run())
}
func fakeComfyUI() {
var port, base string
for i, a := range os.Args {
switch a {
case "--port":
port = os.Args[i+1]
case "--base-directory":
base = os.Args[i+1]
}
}
_ = os.WriteFile(filepath.Join(base, "args"), []byte(strings.Join(os.Args[1:], " ")+"\nkey="+os.Getenv("LLMGW_ENGINE_KEY")), 0o644)
http.HandleFunc("/system_stats", func(w http.ResponseWriter, _ *http.Request) {
// A progress bar's worth of output with every answer: a write that
// failed would end this process, as it fails ComfyUI's workflow.
if _, err := os.Stdout.Write([]byte(strings.Repeat("progress ", 20000) + "\n")); err != nil {
os.Exit(4)
}
_, _ = io.WriteString(w, "{}")
})
http.HandleFunc("/quit", func(w http.ResponseWriter, _ *http.Request) { os.Exit(3) })
if os.Getenv("LLMGW_FAKE_COMFYUI_EXIT") == "1" {
time.AfterFunc(200*time.Millisecond, func() { os.Exit(3) })
}
_ = http.ListenAndServe("127.0.0.1:"+port, nil)
}
func TestTheGateRunsComfyUIAndEndsWithIt(t *testing.T) {
t.Setenv("LLMGW_FAKE_COMFYUI", "1")
weights := filepath.Join(t.TempDir(), "flux.safetensors")
_ = os.WriteFile(weights, []byte("w"), 0o644)
state := t.TempDir()
l, _ := net.Listen("tcp", "127.0.0.1:0")
listen := l.Addr().String()
l.Close()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
done := make(chan error, 1)
go func() {
done <- RunGate(ctx, GateConfig{Listen: listen, Model: "flux", Weights: weights, State: state,
Python: os.Args[0], Dir: t.TempDir(), Args: []string{"--disable-smart-memory"}, Output: io.Discard})
}()
get := func(path string) int {
resp, err := http.Get("http://" + listen + path)
if err != nil {
return 0
}
resp.Body.Close()
return resp.StatusCode
}
deadline := time.Now().Add(10 * time.Second)
for get("/health") != http.StatusOK {
if time.Now().After(deadline) {
t.Fatal("the gate never said ComfyUI was up")
}
time.Sleep(20 * time.Millisecond)
}
b, _ := os.ReadFile(filepath.Join(state, "args"))
for _, want := range []string{"main.py --listen 127.0.0.1 --port", "--base-directory " + state,
"--extra-model-paths-config " + filepath.Join(state, "extra_model_paths.yaml"), "--disable-smart-memory"} {
if !strings.Contains(string(b), want) {
t.Errorf("%q not in what ComfyUI was given: %s", want, b)
}
}
if _, err := os.Lstat(filepath.Join(state, "models", "flux.safetensors")); err != nil {
t.Error("the weights were not shown to ComfyUI:", err)
}
if _, err := os.Stat(filepath.Join(state, "custom_nodes")); err != nil {
t.Error("ComfyUI lists custom_nodes in its base directory first thing, and it is not there:", err)
}
if get("/quit") != http.StatusNotFound {
t.Error("a path the router never asks for was let through")
}
cancel()
select {
case err := <-done:
if err != nil {
t.Errorf("stopped, the gate said %v", err)
}
case <-time.After(25 * time.Second):
t.Fatal("the gate did not stop")
}
}
func TestComfyUIExitingEndsTheGate(t *testing.T) {
t.Setenv("LLMGW_FAKE_COMFYUI", "1")
t.Setenv("LLMGW_FAKE_COMFYUI_EXIT", "1")
weights := filepath.Join(t.TempDir(), "w.safetensors")
_ = os.WriteFile(weights, []byte("w"), 0o644)
err := RunGate(t.Context(), GateConfig{Listen: "127.0.0.1:0", Weights: weights, State: t.TempDir(),
Python: os.Args[0], Dir: t.TempDir(), Output: io.Discard})
if err == nil || !strings.Contains(err.Error(), "exit status 3") {
t.Fatalf("err = %v", err)
}
}
type brokenWriter struct{}
func (brokenWriter) Write([]byte) (int, error) { return 0, errors.New("broken pipe") }
func TestComfyUIKeepsWritingWhenWhatTheGateWritesToIsGone(t *testing.T) {
t.Setenv("LLMGW_FAKE_COMFYUI", "1")
weights := filepath.Join(t.TempDir(), "w.safetensors")
_ = os.WriteFile(weights, []byte("w"), 0o644)
l, _ := net.Listen("tcp", "127.0.0.1:0")
listen := l.Addr().String()
l.Close()
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
done := make(chan error, 1)
go func() {
done <- RunGate(ctx, GateConfig{Listen: listen, Weights: weights, State: t.TempDir(),
Python: os.Args[0], Dir: t.TempDir(), Output: brokenWriter{}})
}()
ok := 0
deadline := time.Now().Add(10 * time.Second)
for ok < 20 && time.Now().Before(deadline) {
resp, err := http.Get("http://" + listen + "/health")
if err == nil {
if resp.StatusCode == http.StatusOK {
ok++
}
resp.Body.Close()
}
select {
case err := <-done:
t.Fatalf("the gate ended: %v", err)
default:
}
time.Sleep(10 * time.Millisecond)
}
if ok < 20 {
t.Fatalf("ComfyUI answered %d times; output it could not hand on stopped it", ok)
}
}
func TestLLMGWsOwnNodesArePutInEveryReplica(t *testing.T) {
state := t.TempDir()
if err := installOwnNodes(state); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(state, "custom_nodes", "llmgw_nodes", "__init__.py"))
if err != nil || !strings.Contains(string(b), "LLMGWPrepareForMesh") {
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")
}
}
+334
View File
@@ -0,0 +1,334 @@
package comfy
import (
"context"
"embed"
"encoding/json"
"fmt"
"io/fs"
"os"
"os/exec"
"path"
"path/filepath"
"regexp"
"runtime"
"slices"
"strconv"
"strings"
"time"
)
// Custom nodes, put in a replica's own base directory at the commit the
// catalog names, their packages installed beside the replica rather than
// into ComfyUI's environment: one model's node cannot break another
// model's replicas, or the pictures every replica makes.
// Node is a custom node as the catalog gives it.
type Node struct {
Repo string `json:"repo"`
Commit string `json:"commit"`
Requirements []string `json:"requirements,omitempty"`
// Build are directories of the node that are packages to compile.
Build []string `json:"build,omitempty"`
}
//go:embed all:nodes
var ownNodes embed.FS
// installOwnNodes writes llmgw's own nodes into a replica's custom_nodes:
// what every workflow may use, from the binary rather than a repository,
// written again at every start so a replica runs the gate's own version.
func installOwnNodes(state string) error {
return fs.WalkDir(ownNodes, "nodes", func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
dst := filepath.Join(state, "custom_nodes", strings.TrimPrefix(p, "nodes"))
if d.IsDir() {
return os.MkdirAll(dst, 0o755)
}
b, err := ownNodes.ReadFile(p)
if err != nil {
return err
}
return os.WriteFile(dst, b, 0o644) //nolint:gosec // code ComfyUI reads, not secret
})
}
// NodesEnv is where the agent hands the gate a model's custom nodes.
const NodesEnv = "LLMGW_COMFYUI_NODES"
var nodeDir = regexp.MustCompile(`[^A-Za-z0-9._-]`)
// installNodes puts each node in state/custom_nodes at its commit and its
// packages in state/site, and returns the PYTHONPATH ComfyUI is started
// with: empty when no node wants a package. Run as the engines' user, in
// the replica's own directory, before ComfyUI starts.
func installNodes(ctx context.Context, state, python string, nodes []Node, uv string, out func(string)) (string, error) {
if len(nodes) == 0 {
return "", nil
}
ctx, cancel := context.WithTimeout(ctx, 20*time.Minute)
defer cancel()
var reqs, built []string
for _, n := range nodes {
name := nodeDir.ReplaceAllString(strings.TrimSuffix(path.Base(n.Repo), ".git"), "_")
dir := filepath.Join(state, "custom_nodes", name)
if head, err := gitOutput(ctx, dir, "rev-parse", "HEAD"); err == nil && head == n.Commit {
reqs = append(reqs, n.Requirements...)
wheels, err := buildNode(ctx, n, dir, python, uv, out)
if err != nil {
return "", err
}
built = append(built, wheels...)
continue
}
out(fmt.Sprintf("custom node %s at %s", n.Repo, n.Commit[:12]))
_ = os.RemoveAll(dir)
for _, args := range [][]string{
{"init", "-q", dir},
{"-C", dir, "remote", "add", "origin", n.Repo},
{"-C", dir, "fetch", "-q", "--depth", "1", "origin", n.Commit},
{"-C", dir, "checkout", "-q", "FETCH_HEAD"},
} {
if b, err := exec.CommandContext(ctx, "git", args...).CombinedOutput(); err != nil { //nolint:gosec // the catalog's repository, checked as https
return "", fmt.Errorf("custom node %s at %s: git %s: %v: %s", n.Repo, n.Commit, args[len(args)-2], err, lastLines(b))
}
}
reqs = append(reqs, n.Requirements...)
wheels, err := buildNode(ctx, n, dir, python, uv, out)
if err != nil {
return "", err
}
built = append(built, wheels...)
}
if len(reqs) == 0 && len(built) == 0 {
return "", nil
}
if uv == "" {
return "", fmt.Errorf("custom nodes need %s, and no uv is installed to install them with", strings.Join(reqs, ", "))
}
// A built package is named by its wheel; ComfyUI's own copy, if one was
// ever put there, must not pin it to another.
named := slices.Clone(reqs)
for _, w := range built {
named = append(named, wheelPackage(w))
}
reqs = append(reqs, built...)
site := filepath.Join(state, "site")
marker := filepath.Join(site, ".llmgw-requirements")
want := strings.Join(reqs, "\n")
if b, err := os.ReadFile(marker); err == nil && string(b) == want {
return site, nil
}
out("installing " + strings.Join(reqs, " ") + " for the custom nodes")
// What ComfyUI's environment has, as constraints: a package both want is
// installed at the version ComfyUI runs, so the replica's copy and
// ComfyUI's never disagree.
freeze, err := exec.CommandContext(ctx, uv, "pip", "freeze", "--python", python).Output() //nolint:gosec // the agent's own uv
if err != nil {
return "", fmt.Errorf("reading ComfyUI's packages: %w", err)
}
constraints := filepath.Join(state, "constraints.txt")
if err := os.WriteFile(constraints, withoutNamed(freeze, named), 0o644); err != nil { //nolint:gosec // not secret
return "", err
}
_ = os.RemoveAll(site)
args := append([]string{"pip", "install", "--python", python, "--target", site, "-c", constraints, "--quiet"}, reqs...)
cmd := exec.CommandContext(ctx, uv, args...) //nolint:gosec // requirements checked by the catalog as package specs
cmd.Env = append(os.Environ(), "UV_NO_CONFIG=1", "UV_CACHE_DIR="+filepath.Join(state, ".uv-cache"))
if b, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("installing the custom nodes' packages (%s): %v: %s", strings.Join(reqs, " "), err, lastLines(b))
}
// The replica's copies come first on ComfyUI's path, but of anything
// both have they are the same version, so which is found is the same.
if err := os.WriteFile(marker, []byte(want), 0o644); err != nil { //nolint:gosec // not secret
return "", err
}
return site, nil
}
// parseNodes reads the nodes the agent handed over.
func parseNodes(s string) ([]Node, error) {
if strings.TrimSpace(s) == "" {
return nil, nil
}
var nodes []Node
if err := json.Unmarshal([]byte(s), &nodes); err != nil {
return nil, fmt.Errorf("%s: %w", NodesEnv, err)
}
return nodes, nil
}
// findUV is the uv the agent installed engines with.
func findUV() string {
if m, _ := filepath.Glob("/opt/llmgw/engines/uv/*/*/uv"); len(m) > 0 {
return m[len(m)-1]
}
if p, err := exec.LookPath("uv"); err == nil {
return p
}
return ""
}
func gitOutput(ctx context.Context, dir string, args ...string) (string, error) {
b, err := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...).Output() //nolint:gosec // fixed arguments
return strings.TrimSpace(string(b)), err
}
func lastLines(b []byte) string {
lines := strings.Split(strings.TrimSpace(string(b)), "\n")
if len(lines) > 4 {
lines = lines[len(lines)-4:]
}
return strings.Join(lines, " | ")
}
// withoutNamed is ComfyUI's packages less those a node names itself: of a
// package a node asks for by name, the node's range decides — TripoSR's
// checkpoint loads with transformers 4 and not 5 — and of everything else,
// what ComfyUI runs. A node that names a package must name a range ComfyUI
// can run with too: the copy it gets is first on ComfyUI's path.
func withoutNamed(freeze []byte, reqs []string) []byte {
named := map[string]bool{}
for _, r := range reqs {
named[packageName(r)] = true
}
var out []string
for _, line := range strings.Split(string(freeze), "\n") {
if strings.TrimSpace(line) == "" || named[packageName(line)] {
continue
}
out = append(out, line)
}
return []byte(strings.Join(out, "\n") + "\n")
}
// packageName is a requirement's package, as PyPI compares them.
func packageName(spec string) string {
s := strings.TrimSpace(spec)
if i := strings.IndexAny(s, "<>=!~;[ @"); i >= 0 {
s = s[:i]
}
return strings.NewReplacer("_", "-", ".", "-").Replace(strings.ToLower(s))
}
// Packages a node's repository has that no index does — C++ and CUDA
// extensions — are built here, for the card the replica runs on: a CUDA
// kernel compiled for one architecture does not load on another, and a
// site's cards are not the PoC's. A build is minutes; its wheel is kept on
// the node, by the node's commit, the directory and the architecture, for
// the next replica on the same kind of card.
// cudaArch is the compute capability of the card the replica sees, as
// TORCH_CUDA_ARCH_LIST wants it ("7.5"). A var for the tests.
var cudaArch = func(ctx context.Context, python string) (string, error) {
cmd := exec.CommandContext(ctx, python, "-c", //nolint:gosec // the agent's own Python
"import torch; print('%d.%d' % torch.cuda.get_device_capability())")
b, err := cmd.Output()
if err != nil {
return "", fmt.Errorf("asking torch for the card's compute capability: %v", err)
}
return strings.TrimSpace(string(b)), nil
}
// buildWheel builds the package in dir as a wheel into outDir, with
// ComfyUI's Python and what it already has (torch, setuptools): a CUDA
// extension builds against the torch it will load with. A var for the tests.
var buildWheel = func(ctx context.Context, uv, python, dir, outDir string, env []string) ([]byte, error) {
cmd := exec.CommandContext(ctx, uv, "build", "--wheel", "--no-build-isolation", //nolint:gosec // the catalog's node, at its commit
"--python", python, "--out-dir", outDir, dir)
cmd.Env = env
return cmd.CombinedOutput()
}
// buildCache is where built wheels are kept: under the engines' home, which
// outlives a replica.
func buildCache() string {
if d, err := os.UserCacheDir(); err == nil {
return filepath.Join(d, "llmgw", "node-builds")
}
return filepath.Join(os.TempDir(), "llmgw-node-builds")
}
// buildNode builds each of a node's packages that are compiled, or finds the
// wheel built before for this commit and card, and returns the wheels.
func buildNode(ctx context.Context, n Node, dir, python, uv string, out func(string)) ([]string, error) {
if len(n.Build) == 0 {
return nil, nil
}
if uv == "" {
return nil, fmt.Errorf("custom node %s has packages to build, and no uv is installed to build them with", n.Repo)
}
arch, err := cudaArch(ctx, python)
if err != nil {
return nil, fmt.Errorf("custom node %s: %w", n.Repo, err)
}
cudaHome := os.Getenv("CUDA_HOME")
if cudaHome == "" {
for _, c := range []string{"/usr/local/cuda"} {
if _, err := os.Stat(filepath.Join(c, "bin", "nvcc")); err == nil {
cudaHome = c
}
}
}
var wheels []string
for _, sub := range n.Build {
cached := filepath.Join(buildCache(), n.Commit[:12], "sm"+strings.ReplaceAll(arch, ".", ""), nodeDir.ReplaceAllString(sub, "_"))
if w, ok := firstWheel(cached); ok {
wheels = append(wheels, w)
continue
}
if cudaHome == "" {
return nil, fmt.Errorf("custom node %s: %s is compiled, and this node has no CUDA toolkit (no nvcc in "+
"CUDA_HOME or /usr/local/cuda) to compile it with", n.Repo, sub)
}
out(fmt.Sprintf("building %s of %s for compute %s", sub, n.Repo, arch))
// Built beside where it is kept and moved there whole: two replicas
// starting at once on one node must not see half a wheel.
if err := os.MkdirAll(filepath.Dir(cached), 0o755); err != nil {
return nil, err
}
tmp, err := os.MkdirTemp(filepath.Dir(cached), ".build-")
if err != nil {
return nil, err
}
env := append(os.Environ(), "TORCH_CUDA_ARCH_LIST="+arch, "CUDA_HOME="+cudaHome,
"PATH="+filepath.Join(cudaHome, "bin")+string(os.PathListSeparator)+os.Getenv("PATH"),
"UV_NO_CONFIG=1", "MAX_JOBS="+strconv.Itoa(max(runtime.NumCPU(), 1)))
b, err := buildWheel(ctx, uv, python, filepath.Join(dir, sub), tmp, env)
if err != nil {
_ = os.RemoveAll(tmp)
return nil, fmt.Errorf("custom node %s: building %s: %v: %s", n.Repo, sub, err, lastLines(b))
}
if _, ok := firstWheel(tmp); !ok {
_ = os.RemoveAll(tmp)
return nil, fmt.Errorf("custom node %s: building %s made no wheel: %s", n.Repo, sub, lastLines(b))
}
if err := os.Rename(tmp, cached); err != nil {
_ = os.RemoveAll(tmp)
// Another replica got there first: its wheel is as good.
if _, ok := firstWheel(cached); !ok {
return nil, err
}
}
w, _ := firstWheel(cached)
wheels = append(wheels, w)
}
return wheels, nil
}
func firstWheel(dir string) (string, bool) {
m, _ := filepath.Glob(filepath.Join(dir, "*.whl"))
if len(m) == 0 {
return "", false
}
return m[0], true
}
// wheelPackage is the package a wheel file is of: its name up to the first
// '-', as PyPI compares names.
func wheelPackage(wheel string) string {
name, _, _ := strings.Cut(filepath.Base(wheel), "-")
return packageName(name)
}
@@ -0,0 +1,183 @@
"""llmgw's own ComfyUI nodes, put in every replica by the gate.
LLMGWPrepareForMesh is what an image-to-3D model wants of its picture, as
TripoSR's own pipeline (tsr/utils.py) does it: the background taken away —
by the picture's alpha where it has one, by rembg (MIT; u2net, Apache 2.0)
where it does not — the object cropped to its bounds, set square in the
middle at a ratio of the side, on grey.
"""
import numpy as np
import torch
from PIL import Image
_sessions = {}
def _rembg(img, model):
try:
import rembg
except ImportError as e: # a model that wants it names it in its nodes' requirements
raise RuntimeError("the picture has no alpha and rembg is not installed for this model: "
"add rembg and onnxruntime to its custom node requirements") from e
if model not in _sessions:
_sessions[model] = rembg.new_session(model)
return rembg.remove(img, session=_sessions[model])
def _fit(rgba, ratio):
"""tsr.utils.resize_foreground: crop to the alpha's bounds, pad square,
pad again so the object is ratio of the side."""
a = np.array(rgba)
ys, xs = np.where(a[..., 3] > 0)
if len(ys) == 0:
raise RuntimeError("nothing is left of the picture once its background is taken away")
fg = a[ys.min():ys.max() + 1, xs.min():xs.max() + 1]
size = max(fg.shape[0], fg.shape[1])
ph0, pw0 = (size - fg.shape[0]) // 2, (size - fg.shape[1]) // 2
sq = np.pad(fg, ((ph0, size - fg.shape[0] - ph0), (pw0, size - fg.shape[1] - pw0), (0, 0)))
new = int(size / ratio)
ph0, pw0 = (new - size) // 2, (new - size) // 2
return np.pad(sq, ((ph0, new - size - ph0), (pw0, new - size - pw0), (0, 0)))
class LLMGWPrepareForMesh:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"foreground_ratio": ("FLOAT", {"default": 0.85, "min": 0.3, "max": 1.0, "step": 0.01}),
"background": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01}),
"remove_background": (["auto", "always", "never"],),
"rembg_model": (["u2net", "isnet-general-use", "u2netp"],),
},
"optional": {"mask": ("MASK",)},
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image", "mask")
FUNCTION = "prepare"
CATEGORY = "llmgw"
def prepare(self, image, foreground_ratio, background, remove_background, rembg_model, mask=None):
out = []
for i in range(image.shape[0]):
rgb = (image[i].cpu().numpy() * 255).clip(0, 255).astype(np.uint8)
h, w = rgb.shape[:2]
alpha = None
# LoadImage's mask is 1 - alpha, and a 64x64 of zeros for a
# picture with none.
if mask is not None and remove_background != "always":
m = mask[min(i, mask.shape[0] - 1)].cpu().numpy()
if m.shape == (h, w) and m.max() > 0:
alpha = ((1.0 - m) * 255).clip(0, 255).astype(np.uint8)
if alpha is None and remove_background != "never":
rgba = np.array(_rembg(Image.fromarray(rgb), rembg_model).convert("RGBA"))
else:
if alpha is None:
alpha = np.full((h, w), 255, np.uint8)
rgba = np.dstack([rgb, alpha])
sq = _fit(rgba, foreground_ratio).astype(np.float32) / 255.0
a = sq[..., 3:4]
img = sq[..., :3] * a + (1.0 - a) * background
out.append(torch.from_numpy(np.dstack([img, a])))
# One size for the batch: the first picture's.
side = out[0].shape[0]
out = [o if o.shape[0] == side else torch.from_numpy(
np.array(Image.fromarray((o.numpy() * 255).astype(np.uint8)).resize((side, side)))).float() / 255.0
for o in out]
both = torch.stack(out)
# The picture on its background, and the object's alpha as a mask
# (1 where the object is) for a model that wants it, as Stable Fast
# 3D's sampler does.
return (both[..., :3].contiguous(), both[..., 3].contiguous())
def _to_linear(x):
return np.where(x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4)
def _to_srgb(x):
return np.where(x <= 0.0031308, x * 12.92, 1.055 * np.power(np.clip(x, 0, None), 1 / 2.4) - 0.055)
def _match_albedo(tex, reference, mask):
"""The texture's brightness brought to the picture's: Stable Fast 3D
takes the light out of what it sees, and a dark object it reads as a
light one dimly lit — a basalt of 0.17 came back 0.67. Scaled in linear
light by the ratio of the medians over the object, so its marks keep
their contrast; texels nothing maps to (black) are left as they are."""
t = np.asarray(tex.convert("RGB")).astype(np.float64) / 255.0
used = t.max(-1) > 2 / 255
ref = reference.cpu().numpy().astype(np.float64)
obj = mask.cpu().numpy() > 0.5 if mask is not None else np.ones(ref.shape[:2], bool)
if used.sum() < 16 or obj.sum() < 16:
return tex
lum = lambda a: a @ np.array([0.2126, 0.7152, 0.0722])
want = np.median(lum(_to_linear(ref[obj])))
have = np.median(lum(_to_linear(t[used])))
if have <= 0:
return tex
k = float(np.clip(want / have, 0.05, 20.0))
out = t.copy()
out[used] = np.clip(_to_srgb(_to_linear(t[used]) * k), 0, 1)
img = Image.fromarray((out * 255 + 0.5).astype(np.uint8))
img.format = tex.format
return img
class LLMGWSaveMesh:
"""Writes each mesh a 3D node made to output/ as a GLB and names the
files, which is how the gate finds a result. A model's own save node
may return the mesh as base64 (Stable Fast 3D's does) or as an OBJ
without its texture, and the gate reads neither.
With albedo_match and the picture the mesh was made from (and its
mask), the mesh's base colour is brought to the picture's brightness."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"mesh": ("MESH",), "filename_prefix": ("STRING", {"default": "mesh"})},
"optional": {"albedo_match": ("BOOLEAN", {"default": False}),
"reference": ("IMAGE",), "reference_mask": ("MASK",)}}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "save"
CATEGORY = "llmgw"
def save(self, mesh, filename_prefix="mesh", albedo_match=False, reference=None, reference_mask=None):
import os
import folder_paths
import trimesh
from trimesh.exchange import gltf
folder, name, counter, subfolder, _ = folder_paths.get_save_image_path(
filename_prefix, folder_paths.get_output_directory())
saved = []
for i, m in enumerate(mesh if isinstance(mesh, (list, tuple)) else [mesh]):
if albedo_match and reference is not None:
_match_scene(m, reference[min(i, reference.shape[0] - 1)],
None if reference_mask is None else reference_mask[min(i, reference_mask.shape[0] - 1)])
file = f"{name.replace('%batch_num%', str(i))}_{counter + i:05}_.glb"
scene = m if isinstance(m, trimesh.Scene) else trimesh.Scene(m)
with open(os.path.join(folder, file), "wb") as f:
f.write(gltf.export_glb(scene, include_normals=True))
saved.append({"filename": file, "type": "output", "subfolder": subfolder})
return {"ui": {"mesh": saved}}
def _match_scene(m, reference, mask):
"""_match_albedo on every base colour texture of a mesh or scene."""
geoms = m.geometry.values() if hasattr(m, "geometry") else [m]
for g in geoms:
mat = getattr(getattr(g, "visual", None), "material", None)
tex = getattr(mat, "baseColorTexture", None)
if tex is not None:
mat.baseColorTexture = _match_albedo(tex, reference, mask)
NODE_CLASS_MAPPINGS = {"LLMGWPrepareForMesh": LLMGWPrepareForMesh, "LLMGWSaveMesh": LLMGWSaveMesh}
NODE_DISPLAY_NAME_MAPPINGS = {"LLMGWPrepareForMesh": "Prepare a picture for a 3D model (llmgw)",
"LLMGWSaveMesh": "Save a mesh as GLB (llmgw)"}
+165
View File
@@ -0,0 +1,165 @@
package comfy
import (
"context"
"os"
"os/exec"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// fakeUV is a uv that answers pip freeze with ComfyUI's packages and records
// what pip install was given.
func fakeUV(t *testing.T, freeze string) (uv, log string) {
t.Helper()
dir := t.TempDir()
log = filepath.Join(dir, "install.log")
uv = filepath.Join(dir, "uv")
script := "#!/bin/sh\nif [ \"$2\" = freeze ]; then printf '%s' '" + freeze + "'; exit 0; fi\n" +
"echo \"$@\" >> " + log + "\n" +
"prev=; for a in \"$@\"; do if [ \"$prev\" = --target ]; then mkdir -p \"$a\"; fi; prev=$a; done\nexit 0\n"
if err := os.WriteFile(uv, []byte(script), 0o755); err != nil {
t.Fatal(err)
}
return uv, log
}
func gitRepo(t *testing.T, files map[string]string) (repo, commit string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
repo = t.TempDir()
run := func(args ...string) string {
cmd := exec.Command("git", args...)
cmd.Dir = repo
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
b, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v %s", args, err, b)
}
return strings.TrimSpace(string(b))
}
run("init", "-q")
run("config", "uploadpack.allowReachableSHA1InWant", "true")
for name, body := range files {
_ = os.MkdirAll(filepath.Dir(filepath.Join(repo, name)), 0o755)
_ = os.WriteFile(filepath.Join(repo, name), []byte(body), 0o644)
}
run("add", ".")
run("commit", "-qm", "one")
return repo, run("rev-parse", "HEAD")
}
func TestANodesCompiledPackagesAreBuiltForTheCardOnceAndInstalledBesideTheReplica(t *testing.T) {
repo, commit := gitRepo(t, map[string]string{"__init__.py": "", "texture_baker/setup.py": "", "uv_unwrapper/setup.py": ""})
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
cuda := t.TempDir()
_ = os.MkdirAll(filepath.Join(cuda, "bin"), 0o755)
_ = os.WriteFile(filepath.Join(cuda, "bin", "nvcc"), nil, 0o755)
t.Setenv("CUDA_HOME", cuda)
var builds atomic.Int32
var envSeen []string
oldArch, oldBuild := cudaArch, buildWheel
t.Cleanup(func() { cudaArch, buildWheel = oldArch, oldBuild })
cudaArch = func(context.Context, string) (string, error) { return "7.5", nil }
buildWheel = func(_ context.Context, _, _, dir, out string, env []string) ([]byte, error) {
builds.Add(1)
envSeen = env
name := strings.ReplaceAll(filepath.Base(dir), "_", "-")
return nil, os.WriteFile(filepath.Join(out, strings.ReplaceAll(name, "-", "_")+"-0.0.1-cp312-cp312-linux_x86_64.whl"), []byte("w"), 0o644)
}
// ComfyUI's environment has an old hand-installed copy, which must not
// pin the built one.
uv, log := fakeUV(t, "numpy==2.5.3\ntexture-baker @ file:///home/someone/texture_baker-0.0.1.whl\n")
node := Node{Repo: "file://" + repo, Commit: commit, Requirements: []string{"trimesh"},
Build: []string{"texture_baker", "uv_unwrapper"}}
state := t.TempDir()
site, err := installNodes(t.Context(), state, "python3", []Node{node}, uv, func(string) {})
if err != nil {
t.Fatal(err)
}
if site == "" || builds.Load() != 2 {
t.Fatalf("site %q, %d builds", site, builds.Load())
}
if !strings.Contains(strings.Join(envSeen, " "), "TORCH_CUDA_ARCH_LIST=7.5") {
t.Error("the build was not told the card's architecture")
}
installed, _ := os.ReadFile(log)
if !strings.Contains(string(installed), "trimesh") || !strings.Contains(string(installed), "sm75/texture_baker/texture_baker-0.0.1") ||
!strings.Contains(string(installed), "uv_unwrapper-0.0.1") {
t.Errorf("installed %s", installed)
}
constraints, _ := os.ReadFile(filepath.Join(state, "constraints.txt"))
if strings.Contains(string(constraints), "texture-baker") || !strings.Contains(string(constraints), "numpy==2.5.3") {
t.Errorf("constraints %s", constraints)
}
// A second replica on the same kind of card finds the wheels built.
if _, err := installNodes(t.Context(), t.TempDir(), "python3", []Node{node}, uv, func(string) {}); err != nil {
t.Fatal(err)
}
if builds.Load() != 2 {
t.Errorf("built again: %d builds", builds.Load())
}
// Another kind of card builds its own.
cudaArch = func(context.Context, string) (string, error) { return "8.9", nil }
if _, err := installNodes(t.Context(), t.TempDir(), "python3", []Node{node}, uv, func(string) {}); err != nil {
t.Fatal(err)
}
if builds.Load() != 4 {
t.Errorf("an Ada used Turing's wheels: %d builds", builds.Load())
}
}
func TestANodeToCompileOnAMachineWithoutCUDASaysSo(t *testing.T) {
repo, commit := gitRepo(t, map[string]string{"__init__.py": "", "ext/setup.py": ""})
t.Setenv("XDG_CACHE_HOME", t.TempDir())
t.Setenv("HOME", t.TempDir())
t.Setenv("CUDA_HOME", "")
if _, err := os.Stat("/usr/local/cuda/bin/nvcc"); err == nil {
t.Skip("this machine has a CUDA toolkit")
}
oldArch := cudaArch
t.Cleanup(func() { cudaArch = oldArch })
cudaArch = func(context.Context, string) (string, error) { return "8.0", nil }
uv, _ := fakeUV(t, "")
_, err := installNodes(t.Context(), t.TempDir(), "python3", []Node{{Repo: "file://" + repo, Commit: commit, Build: []string{"ext"}}},
uv, func(string) {})
if err == nil || !strings.Contains(err.Error(), "no CUDA toolkit") {
t.Errorf("%v", err)
}
}
func TestWeightsANodeLoadsByRepositoryNameAreInTheHuggingFaceCache(t *testing.T) {
weights := t.TempDir()
_ = os.WriteFile(filepath.Join(weights, "config.yaml"), []byte("c"), 0o644)
_ = os.WriteFile(filepath.Join(weights, "model.safetensors"), []byte("m"), 0o644)
hub := t.TempDir()
if err := LinkHFCache(weights, "stabilityai/stable-fast-3d", hub); err != nil {
t.Fatal(err)
}
root := filepath.Join(hub, "models--stabilityai--stable-fast-3d")
ref, _ := os.ReadFile(filepath.Join(root, "refs", "main"))
if len(ref) != 40 {
t.Fatalf("refs/main %q is not a commit's form", ref)
}
b, err := os.ReadFile(filepath.Join(root, "snapshots", string(ref), "model.safetensors"))
if err != nil || string(b) != "m" {
t.Errorf("the weights through the cache: %q %v", b, err)
}
// Again, as a replica started a second time does.
if err := LinkHFCache(weights, "stabilityai/stable-fast-3d", hub); err != nil {
t.Errorf("a second time: %v", err)
}
for _, bad := range []string{"noslash", "a/b/c", "../x", "a/.."} {
if err := LinkHFCache(weights, bad, hub); err == nil {
t.Errorf("%q taken for a repository", bad)
}
}
}
+223
View File
@@ -0,0 +1,223 @@
package comfy
import (
"bytes"
"context"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/net/websocket"
)
// How far along a workflow is, which ComfyUI says only on its websocket and
// only to the client that queued the workflow. The gate keeps one websocket
// open to its ComfyUI, under a client id of its own, puts that id on every
// workflow it lets through, and keeps the last word on each: the node
// running and its step out of how many. The router asks at
// /progress/{prompt_id} while it waits, and a job says it.
// Progress is a workflow's state as the gate last heard it.
type Progress struct {
PromptID string `json:"prompt_id,omitempty"`
// State is queued, running, done or failed.
State string `json:"state"`
// Ahead is how many workflows wait before it, while it is queued.
Ahead int `json:"ahead,omitempty"`
// Node is the workflow's node running, by its id; NodeClass its kind.
Node string `json:"node,omitempty"`
NodeClass string `json:"node_class,omitempty"`
// Step of Steps is the running node's own count: a sampler's steps.
Step int `json:"step,omitempty"`
Steps int `json:"steps,omitempty"`
// Percent is Step of Steps, where a node counts.
Percent int `json:"percent,omitempty"`
Error string `json:"error,omitempty"`
}
// progressBook is the last word on each of the most recent workflows.
type progressBook struct {
mu sync.Mutex
by map[string]*Progress
order []string
}
// keptPrompts bounds the book: a gate serves one replica, which runs one
// workflow at a time and queues a few.
const keptPrompts = 256
func newProgressBook() *progressBook { return &progressBook{by: map[string]*Progress{}} }
func (b *progressBook) update(id string, f func(*Progress)) {
if id == "" {
return
}
b.mu.Lock()
defer b.mu.Unlock()
p, ok := b.by[id]
if !ok {
p = &Progress{PromptID: id, State: "queued"}
b.by[id] = p
b.order = append(b.order, id)
for len(b.order) > keptPrompts {
delete(b.by, b.order[0])
b.order = b.order[1:]
}
}
f(p)
}
func (b *progressBook) get(id string) (Progress, bool) {
b.mu.Lock()
defer b.mu.Unlock()
p, ok := b.by[id]
if !ok {
return Progress{}, false
}
return *p, true
}
// read takes one message off ComfyUI's websocket into the book.
func (b *progressBook) read(msg []byte) {
var m struct {
Type string `json:"type"`
Data struct {
PromptID string `json:"prompt_id"`
Node json.RawMessage `json:"node"`
Value int `json:"value"`
Max int `json:"max"`
Message string `json:"exception_message"`
NodeType string `json:"node_type"`
} `json:"data"`
}
if json.Unmarshal(msg, &m) != nil {
return
}
d := m.Data
var node string
_ = json.Unmarshal(d.Node, &node)
switch m.Type {
case "execution_start":
b.update(d.PromptID, func(p *Progress) { p.State, p.Ahead = "running", 0 })
case "executing":
b.update(d.PromptID, func(p *Progress) {
if node == "" {
// No node: the workflow is through.
p.State, p.Node, p.Step, p.Steps, p.Percent = "done", "", 0, 0, 100
return
}
p.State, p.Ahead, p.Node, p.Step, p.Steps, p.Percent = "running", 0, node, 0, 0, 0
})
case "progress":
b.update(d.PromptID, func(p *Progress) {
p.State, p.Step, p.Steps = "running", d.Value, d.Max
if node != "" {
p.Node = node
}
if d.Max > 0 {
p.Percent = min(100, d.Value*100/d.Max)
}
})
case "execution_success":
b.update(d.PromptID, func(p *Progress) { p.State, p.Percent = "done", 100 })
case "execution_error", "execution_interrupted":
b.update(d.PromptID, func(p *Progress) {
p.State = "failed"
p.Error = strings.TrimSpace(d.NodeType + ": " + d.Message)
if m.Type == "execution_interrupted" {
p.Error = "interrupted"
}
})
}
}
// watch keeps a websocket open to the ComfyUI at upstream under clientID,
// reading into the book, until ctx ends: opened again when it closes, as it
// does whenever ComfyUI restarts.
func (b *progressBook) watch(ctx context.Context, upstream *url.URL, clientID string, log *slog.Logger) {
ws := "ws://" + upstream.Host + "/ws?clientId=" + url.QueryEscape(clientID)
wait := time.Second
for ctx.Err() == nil {
conn, err := websocket.Dial(ws, "", "http://"+upstream.Host)
if err == nil {
wait = time.Second
go func() { <-ctx.Done(); conn.Close() }()
for {
var msg []byte
if err := websocket.Message.Receive(conn, &msg); err != nil {
break
}
// Binary frames are previews of pictures; only text is news.
if len(msg) > 0 && msg[0] == '{' {
b.read(msg)
}
}
conn.Close()
} else if log != nil {
log.Debug("ComfyUI's websocket is not open yet", "err", err)
}
select {
case <-ctx.Done():
return
case <-time.After(wait):
}
wait = min(wait*2, 30*time.Second)
}
}
// withClient is a /prompt body with the gate's client id in it, so the
// progress of the workflow it queues comes to the gate's websocket.
func withClient(body []byte, clientID string) []byte {
var m map[string]json.RawMessage
if json.Unmarshal(body, &m) != nil {
return body
}
id, _ := json.Marshal(clientID)
m["client_id"] = id
out, err := json.Marshal(m)
if err != nil {
return body
}
return out
}
// promptThroughGate forwards a /prompt with the gate's client id, and books
// the workflow it queued as queued.
func (b *progressBook) promptThroughGate(proxy http.Handler, clientID string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(io.LimitReader(r.Body, 64<<20))
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
body = withClient(body, clientID)
r.Body = io.NopCloser(bytes.NewReader(body))
r.ContentLength = int64(len(body))
rec := &captured{ResponseWriter: w}
proxy.ServeHTTP(rec, r)
var out struct {
PromptID string `json:"prompt_id"`
}
if json.Unmarshal(rec.body.Bytes(), &out) == nil && out.PromptID != "" {
b.update(out.PromptID, func(*Progress) {})
}
}
}
// captured passes a response on and keeps a copy of the first of it.
type captured struct {
http.ResponseWriter
body bytes.Buffer
}
func (c *captured) Write(p []byte) (int, error) {
if c.body.Len() < 64<<10 {
c.body.Write(p)
}
return c.ResponseWriter.Write(p)
}
+192
View File
@@ -0,0 +1,192 @@
package comfy
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
func TestTheBookReadsComfyUIsWebsocket(t *testing.T) {
b := newProgressBook()
for _, m := range []string{
`{"type":"status","data":{"status":{"exec_info":{"queue_remaining":1}}}}`,
`{"type":"execution_start","data":{"prompt_id":"p1"}}`,
`{"type":"executing","data":{"node":"5","prompt_id":"p1"}}`,
`{"type":"progress","data":{"value":12,"max":30,"prompt_id":"p1","node":"5"}}`,
} {
b.read([]byte(m))
}
p, _ := b.get("p1")
if p.State != "running" || p.Node != "5" || p.Step != 12 || p.Steps != 30 || p.Percent != 40 {
t.Errorf("%+v", p)
}
b.read([]byte(`{"type":"executing","data":{"node":null,"prompt_id":"p1"}}`))
if p, _ := b.get("p1"); p.State != "done" || p.Percent != 100 {
t.Errorf("finished: %+v", p)
}
b.read([]byte(`{"type":"execution_error","data":{"prompt_id":"p2","node_type":"KSampler","exception_message":"CUDA out of memory"}}`))
if p, _ := b.get("p2"); p.State != "failed" || p.Error != "KSampler: CUDA out of memory" {
t.Errorf("failed: %+v", p)
}
for i := range keptPrompts + 10 {
b.update(fmt.Sprint("x", i), func(*Progress) {})
}
if _, ok := b.get("p1"); ok || len(b.by) != keptPrompts {
t.Errorf("the book kept %d", len(b.by))
}
}
func TestTheGateHearsTheWorkflowsItLetsThrough(t *testing.T) {
var mu sync.Mutex
var sent map[string]any
comfy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/prompt" {
mu.Lock()
_ = json.NewDecoder(r.Body).Decode(&sent)
mu.Unlock()
fmt.Fprint(w, `{"prompt_id":"p9"}`)
}
}))
defer comfy.Close()
u, _ := url.Parse(comfy.URL)
book := newProgressBook()
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 {
t.Fatal(err)
}
b, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(b), "p9") {
t.Fatalf("the answer did not come through: %s", b)
}
mu.Lock()
if sent["client_id"] != "llmgw-gate-1" {
t.Errorf("ComfyUI was sent client %v: its progress would go to nobody listening", sent["client_id"])
}
mu.Unlock()
book.read([]byte(`{"type":"progress","data":{"value":3,"max":4,"prompt_id":"p9","node":"2"}}`))
resp, _ = http.Get(gate.URL + "/progress/p9")
var p Progress
_ = json.NewDecoder(resp.Body).Decode(&p)
resp.Body.Close()
if p.State != "running" || p.Percent != 75 {
t.Errorf("/progress: %+v", p)
}
if resp, _ := http.Get(gate.URL + "/progress/unknown"); resp.StatusCode != http.StatusNotFound {
t.Errorf("an unknown workflow: %d", resp.StatusCode)
}
}
func TestAWaitingWorkflowSaysHowManyAreAheadAndThenHowFarAlong(t *testing.T) {
var mu sync.Mutex
stage := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
mu.Lock()
s := stage
mu.Unlock()
switch r.URL.Path {
case "/prompt":
fmt.Fprint(w, `{"prompt_id":"me"}`)
case "/queue":
if s == 0 {
fmt.Fprint(w, `{"queue_running":[[1,"a"]],"queue_pending":[[3,"me"],[2,"b"],[4,"c"]]}`)
} else {
fmt.Fprint(w, `{"queue_running":[[3,"me"]],"queue_pending":[]}`)
}
case "/progress/me":
fmt.Fprint(w, `{"state":"running","node":"5","step":6,"steps":12,"percent":50}`)
case "/history/me":
if s < 2 {
fmt.Fprint(w, `{}`)
return
}
fmt.Fprint(w, `{"me":{"status":{"status_str":"success","completed":true},"outputs":{"9":{"images":[{"filename":"a.png","type":"temp"}]}}}}`)
case "/view":
w.Header().Set("Content-Type", "image/png")
fmt.Fprint(w, "png")
}
}))
defer srv.Close()
var seen []Progress
c := &Client{Poll: time.Millisecond}
done := make(chan error, 1)
go func() {
_, err := c.GenerateWatch(t.Context(), srv.URL, "", map[string]any{"5": map[string]any{"class_type": "KSampler"}},
func(p Progress) {
mu.Lock()
seen = append(seen, p)
stage++
mu.Unlock()
})
done <- err
}()
if err := <-done; err != nil {
t.Fatal(err)
}
if len(seen) < 2 || seen[0].State != "queued" || seen[0].Ahead != 2 {
t.Fatalf("%+v: queued behind the one running and the one numbered before it, wanted first", seen)
}
if seen[1].State != "running" || seen[1].Percent != 50 || seen[1].NodeClass != "KSampler" {
t.Errorf("then %+v", seen[1])
}
}
func TestANodeIsPutAtItsCommit(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
repo := t.TempDir()
run := func(args ...string) string {
cmd := exec.Command("git", args...)
cmd.Dir = repo
cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@t", "GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@t")
b, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("git %v: %v %s", args, err, b)
}
return strings.TrimSpace(string(b))
}
run("init", "-q")
run("config", "uploadpack.allowReachableSHA1InWant", "true")
_ = os.WriteFile(filepath.Join(repo, "__init__.py"), []byte("v1\n"), 0o644)
run("add", ".")
run("commit", "-qm", "one")
first := run("rev-parse", "HEAD")
_ = os.WriteFile(filepath.Join(repo, "__init__.py"), []byte("v2\n"), 0o644)
run("commit", "-qam", "two")
state := t.TempDir()
site, err := installNodes(t.Context(), state, "python3", []Node{{Repo: "file://" + repo, Commit: first}}, "", func(string) {})
if err != nil {
t.Fatal(err)
}
b, _ := os.ReadFile(filepath.Join(state, "custom_nodes", filepath.Base(repo), "__init__.py"))
if string(b) != "v1\n" || site != "" {
t.Errorf("the node is %q, site %q: wanted the commit named, not the branch's head", b, site)
}
if _, err := installNodes(t.Context(), state, "python3", []Node{{Repo: "file://" + repo, Commit: first,
Requirements: []string{"trimesh"}}}, "", func(string) {}); err == nil || !strings.Contains(err.Error(), "no uv") {
t.Errorf("packages without a uv: %v", err)
}
}
func TestANodesOwnRangeDecidesThePackagesItNames(t *testing.T) {
freeze := []byte("numpy==2.3.1\ntransformers==5.19.0\nhuggingface-hub==1.33.0\nPillow==12.3.0\n")
got := string(withoutNamed(freeze, []string{"transformers>=4.50.3,<5", "Huggingface_Hub<1", "trimesh"}))
if strings.Contains(got, "transformers") || strings.Contains(got, "huggingface") ||
!strings.Contains(got, "numpy==2.3.1") || !strings.Contains(got, "Pillow==12.3.0") {
t.Errorf("constraints %q", got)
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"1": {"class_type": "LoadImage", "inputs": {"image": "{{image}}"}},
"5": {"class_type": "LLMGWPrepareForMesh", "inputs": {"image": ["1", 0], "mask": ["1", 1],
"foreground_ratio": 0.85, "background": 0.5, "remove_background": "auto", "rembg_model": "u2net"}},
"2": {"class_type": "TripoSRModelLoader", "inputs": {"model": "{{checkpoint}}", "chunk_size": 8192}},
"3": {"class_type": "TripoSRSampler", "inputs": {"model": ["2", 0], "reference_image": ["5", 0],
"geometry_resolution": "{{resolution|256}}", "threshold": 25.0}},
"4": {"class_type": "TripoSRViewer", "inputs": {"mesh": ["3", 0]}}
}
+18 -1
View File
@@ -158,6 +158,13 @@ type Server struct {
// Firewall turns llmgw's firewall on every machine on or off.
Firewall FirewallSettings `koanf:"firewall"`
// 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
@@ -493,6 +500,11 @@ type Personal struct {
type Price struct {
PromptPer1k int64 `koanf:"prompt_per_1k"`
CompletionPer1k int64 `koanf:"completion_per_1k"`
// PerImage is the cost of one picture, for a model that makes them
// (/v1/images/generations), on top of its prompt's tokens.
PerImage int64 `koanf:"per_image"`
// PerMesh is the cost of one 3D model (/v1/3d/generations).
PerMesh int64 `koanf:"per_mesh"`
}
// Control configures the control plane.
@@ -538,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"`
}
@@ -686,6 +698,10 @@ type Band struct {
Name string `koanf:"name"`
Priority int `koanf:"priority"`
SaturationCeiling float64 `koanf:"saturation_ceiling"`
// QueueTTL is how long this band's requests may wait for room; the
// admission's queue_ttl unless set. Long for a band of bulk work, which
// had rather wait behind people than be turned away.
QueueTTL time.Duration `koanf:"queue_ttl"`
}
// Timeouts bounds each phase of an upstream request.
@@ -786,6 +802,7 @@ func Default() Server {
MaxConnections: 10_000,
Mirror: Mirror{UpdateCheck: 6 * time.Hour},
Firewall: FirewallSettings{Enabled: true},
Presets: PresetSettings{Auto: true},
Control: Control{
Interval: 5 * time.Second,
BaseBackoff: 10 * time.Second,
+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
@@ -0,0 +1,13 @@
package config
// PresetSettings is how the models that come with llmgw reach the catalog.
type PresetSettings struct {
// Auto has the leading control plane add a picture model (flux-schnell)
// and a 3D model (triposr) by itself, on demand, once a node has a card
// that fits one and the catalog has no model that makes the same kind of
// thing — so a new cluster's portal has its pictures and 3D pages
// without anybody writing a definition. Only where the cluster already
// installs ComfyUI (engines.comfyui). A preset somebody removed is not
// added again by itself. In force on the leader within a minute.
Auto bool `koanf:"auto"`
}
+18 -6
View File
@@ -81,11 +81,15 @@ type API struct {
// revoke records a removed node, so its certificate is refused.
revoke func(ctx context.Context, nodeID string, at time.Time) error
models Models
capacity Capacity
usage Usage
quotas Quotas
ctl *control.Controller
models Models
// presets keeps what became of each preset, presetCfg reads the
// settings in force; nil without a store.
presets PresetStore
presetCfg func() PresetConfig
capacity Capacity
usage Usage
quotas Quotas
ctl *control.Controller
}
// New builds the API over a controller.
@@ -166,6 +170,7 @@ func (a *API) Register(mux Mux) {
}
writeJSON(w, http.StatusOK, t)
})
a.registerPresets(mux)
mux.HandleFunc("GET /api/v1/models", a.listModels)
mux.HandleFunc("GET /api/v1/replicas", a.listReplicas)
// Listing is a read and deleting is a separate verb, so a mistyped
@@ -203,6 +208,9 @@ func (a *API) Register(mux Mux) {
mux.HandleFunc("POST /api/v1/models/{name}/origin", a.setOrigin)
mux.HandleFunc("GET /api/v1/plan", a.planModel)
mux.HandleFunc("POST /api/v1/metrics/range", a.metricsBatch)
mux.HandleFunc("GET /api/v1/client-jobs", a.listJobs)
mux.HandleFunc("GET /api/v1/client-jobs/{id}", a.getJob)
mux.HandleFunc("POST /api/v1/client-jobs/{id}/cancel", a.cancelJob)
mux.HandleFunc("GET /api/v1/requests/{id}", a.request)
mux.HandleFunc("GET /api/v1/requests/{id}/body", a.requestBody)
mux.HandleFunc("POST /api/v1/requests/{id}/replay", a.replayRequest)
@@ -1226,6 +1234,8 @@ func (a *API) removeModel(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, err.Error())
return
}
// A preset removed is one the leader must not put back by itself.
a.presetDeclined(r.Context(), name, actorOf(r))
writeJSON(w, http.StatusOK, Removed{Removed: name})
}
@@ -1611,6 +1621,8 @@ type RequestView struct {
TTFTMs int64 `json:"ttft_ms"`
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
Images int64 `json:"images,omitempty"`
Meshes int64 `json:"meshes,omitempty"`
CostMicros int64 `json:"cost_micros"`
// What the guardrails did, by name.
GuardAction string `json:"guard_action,omitempty"`
@@ -1644,7 +1656,7 @@ func (a *API) request(w http.ResponseWriter, r *http.Request) {
RequestID: u.RequestID, At: u.At, KeyID: u.KeyID, UserID: u.UserID, ProjectID: u.ProjectID,
Model: u.Model, ServedModel: u.ServedModel, Replica: u.Replica, Status: u.Status,
LatencyMs: u.LatencyMs, QueueMs: u.QueueMs, TTFTMs: u.TTFTMs,
PromptTokens: u.PromptTokens, CompletionTokens: u.CompletionTokens, CostMicros: u.CostMicros,
PromptTokens: u.PromptTokens, CompletionTokens: u.CompletionTokens, Images: u.Images, Meshes: u.Meshes, CostMicros: u.CostMicros,
GuardAction: u.GuardAction, GuardPolicy: u.GuardPolicy, GuardCategory: u.GuardCategory, GuardRule: u.GuardRule, GuardMs: u.GuardMs,
EndUser: u.EndUser, Tags: u.Tags, CacheHit: u.CacheHit,
})
+2 -2
View File
@@ -75,11 +75,11 @@ func (a *API) usageExport(w http.ResponseWriter, r *http.Request) {
func WriteExportCSV(w io.Writer, rows []store.ExportRow) {
c := csv.NewWriter(w)
_ = c.Write([]string{"period_start", "org", "team", "project", "user", "key", "model",
"requests", "errors", "limited", "prompt_tokens", "completion_tokens", "cost_micros", "cost"})
"requests", "errors", "limited", "prompt_tokens", "completion_tokens", "cost_micros", "cost", "images", "meshes"})
for _, r := range rows {
_ = c.Write([]string{r.Period.Format(time.RFC3339), safe(r.Org), safe(r.Team), safe(r.Project), safe(r.User),
r.Key, safe(r.Model), i(r.Requests), i(r.Errors), i(r.Limited), i(r.Prompt), i(r.Completion),
i(r.CostMicros), strconv.FormatFloat(float64(r.CostMicros)/1e6, 'f', 6, 64)})
i(r.CostMicros), strconv.FormatFloat(float64(r.CostMicros)/1e6, 'f', 6, 64), i(r.Images), i(r.Meshes)})
}
c.Flush()
}
+115
View File
@@ -0,0 +1,115 @@
package adminapi
import (
"context"
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// Jobs — 3D models asked for and asked after — as an operator sees them:
// every owner's, which a client sees only of its own.
// jobStore is what jobs need of the store; asked of the usage source by
// type assertion, as requestFinder is.
type jobStore interface {
AllJobs(ctx context.Context, status string, limit int) ([]store.Job, error)
JobByID(ctx context.Context, id string) (store.Job, error)
CancelJob(ctx context.Context, id string, now time.Time) (store.Job, error)
}
// JobView is one job.
type JobView struct {
ID string `json:"id"`
Owner string `json:"owner"`
KeyID string `json:"key_id"`
Endpoint string `json:"endpoint"`
Model string `json:"model"`
Status string `json:"status"`
Progress json.RawMessage `json:"progress,omitempty"`
OutputFileID string `json:"output_file_id,omitempty"`
ContentType string `json:"content_type,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
StartedAt *time.Time `json:"started_at,omitempty"`
FinishedAt *time.Time `json:"finished_at,omitempty"`
}
func jobView(j store.Job) JobView {
v := JobView{ID: j.ID, Owner: j.Owner, KeyID: j.KeyID, Endpoint: j.Endpoint, Model: j.Model, Status: j.Status,
OutputFileID: j.OutputFileID, ContentType: j.ContentType, Error: j.Error, CreatedAt: j.CreatedAt}
if j.Progress != "" && json.Valid([]byte(j.Progress)) {
v.Progress = json.RawMessage(j.Progress)
}
if !j.StartedAt.IsZero() {
v.StartedAt = &j.StartedAt
}
if !j.FinishedAt.IsZero() {
v.FinishedAt = &j.FinishedAt
}
return v
}
func (a *API) jobs() (jobStore, bool) {
s, ok := a.usage.(jobStore)
return s, ok
}
func (a *API) listJobs(w http.ResponseWriter, r *http.Request) {
s, ok := a.jobs()
if !ok {
writeError(w, http.StatusNotImplemented, "jobs need the store, which this control plane runs without")
return
}
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
js, err := s.AllJobs(r.Context(), r.URL.Query().Get("status"), limit)
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]JobView, 0, len(js))
for _, j := range js {
out = append(out, jobView(j))
}
writeJSON(w, http.StatusOK, out)
}
func (a *API) getJob(w http.ResponseWriter, r *http.Request) {
s, ok := a.jobs()
if !ok {
writeError(w, http.StatusNotImplemented, "jobs need the store, which this control plane runs without")
return
}
j, err := s.JobByID(r.Context(), r.PathValue("id"))
if errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "no job "+r.PathValue("id"))
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, jobView(j))
}
func (a *API) cancelJob(w http.ResponseWriter, r *http.Request) {
s, ok := a.jobs()
if !ok {
writeError(w, http.StatusNotImplemented, "jobs need the store, which this control plane runs without")
return
}
if _, err := s.JobByID(r.Context(), r.PathValue("id")); errors.Is(err, store.ErrNotFound) {
writeError(w, http.StatusNotFound, "no job "+r.PathValue("id"))
return
}
j, err := s.CancelJob(r.Context(), r.PathValue("id"), time.Now())
if err != nil {
writeError(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, jobView(j))
}
+353
View File
@@ -0,0 +1,353 @@
package adminapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/provision"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
// PresetsView is the models that come with llmgw, and whether the leader adds
// them by itself.
type PresetsView struct {
// Auto is whether a preset is added when a node fits it and the catalog
// has no model that makes what it makes (presets.auto).
Auto bool `json:"auto"`
// ComfyUI is engines.comfyui as it stands: the version the nodes
// install, "" for none — in which case adding a preset sets it, and the
// control planes start again on that.
ComfyUI string `json:"comfyui"`
Presets []PresetView `json:"presets"`
}
// PresetView is one preset as it stands in this cluster.
type PresetView struct {
Name string `json:"name"`
Makes string `json:"makes"`
Description string `json:"description"`
Card string `json:"card"`
Licence catalog.License `json:"licence"`
// Fits is whether some node has a card that meets the preset's need;
// FitsClasses names those classes.
Fits bool `json:"fits"`
FitsClasses []string `json:"fits_classes,omitempty"`
InCatalog bool `json:"in_catalog"`
// AddedBy is "auto" when the leader added it, who did otherwise, ""
// when it was not added as a preset; AddedAt is when.
AddedBy string `json:"added_by,omitempty"`
AddedAt time.Time `json:"added_at,omitzero"`
// Declined is whether somebody removed it after it was added, so it is
// not added again by itself; adding it again clears that.
Declined bool `json:"declined"`
// Blocked is why the leader does not add it by itself — presets.auto
// off, no ComfyUI, no card that fits, declined, another model already
// making the same — empty when it would or has.
Blocked string `json:"blocked,omitempty"`
}
// PresetsAutoRequest turns the automatic adding of presets on or off.
type PresetsAutoRequest struct {
On bool `json:"on"`
}
// errPresetsUnavailable is a control plane without the store presets are
// kept in.
var errPresetsUnavailable = errors.New("this control plane has no store, so presets cannot be added here")
// PresetStore keeps what became of each preset, and the audit log the
// leader writes its own additions to.
type PresetStore interface {
PresetStates(ctx context.Context) (map[string]store.PresetState, error)
PresetAdded(ctx context.Context, name, by string, at time.Time) error
PresetDeclined(ctx context.Context, name, by string, at time.Time) error
WriteAudit(ctx context.Context, e store.AuditEvent) error
}
// PresetConfig is what the presets read of the settings in force.
type PresetConfig struct {
// Auto is presets.auto.
Auto bool
// ComfyUI is engines.comfyui: the version the nodes install, empty for
// none.
ComfyUI string
}
// SetPresets enables the presets: where what became of them is kept, and
// the settings in force.
func (a *API) SetPresets(st PresetStore, cfg func() PresetConfig) {
a.presets, a.presetCfg = st, cfg
}
// Presets is the presets as they stand in this cluster.
func (a *API) Presets(ctx context.Context) (PresetsView, error) {
if a.presets == nil || a.presetCfg == nil {
return PresetsView{}, errPresetsUnavailable
}
states, err := a.presets.PresetStates(ctx)
if err != nil {
return PresetsView{}, err
}
cfg := a.presetCfg()
cat := a.ctl.Catalog()
kinds := a.gpuKinds()
out := PresetsView{Auto: cfg.Auto, ComfyUI: cfg.ComfyUI}
for _, p := range catalog.Presets() {
v := PresetView{Name: p.Model.Name, Makes: p.Makes, Description: p.Model.Description,
Card: presetCard(p.Model), FitsClasses: presetFits(p.Model, kinds)}
if p.Model.License != nil {
v.Licence = *p.Model.License
}
v.Fits = len(v.FitsClasses) > 0
_, v.InCatalog = cat.Resolve(p.Model.Name)
if st, ok := states[p.Model.Name]; ok {
v.AddedBy, v.AddedAt, v.Declined = st.AddedBy, st.AddedAt, st.Declined
}
v.Blocked = presetBlocked(p, v, cfg, cat)
out.Presets = append(out.Presets, v)
}
return out, nil
}
// AddPreset adds the preset of that name, and clears its decline. A cluster
// that installs no ComfyUI yet is set to (engines.comfyui), as `llmgw ctl
// model add` does; that change starts the control planes again.
func (a *API) AddPreset(ctx context.Context, name, by string) error {
if a.presets == nil || a.presetCfg == nil || a.models == nil {
return errPresetsUnavailable
}
p, ok := catalog.PresetNamed(name)
if !ok {
return fmt.Errorf("%w: %q (there are %s)", errNoSuchPreset, name, strings.Join(catalog.PresetNames(), ", "))
}
if _, in := a.ctl.Catalog().Resolve(name); in {
return fmt.Errorf("%s is already in the catalog", name)
}
if a.presetCfg().ComfyUI == "" {
if err := a.setComfyUI(ctx, by); err != nil {
return err
}
}
if err := a.models.Add(ctx, p.Model, by); err != nil {
return err
}
return a.presets.PresetAdded(ctx, name, by, time.Now())
}
// SetAutoPresets turns presets.auto on or off.
func (a *API) SetAutoPresets(ctx context.Context, on bool, by string) error {
if a.presets == nil {
return errPresetsUnavailable
}
note := "presets added by themselves: " + map[bool]string{true: "on", false: "off"}[on]
return a.editSettings(ctx, []string{"presets", "auto"}, strconv.FormatBool(on), by, note)
}
// PresetsPass adds, as "auto", each preset a node fits whose kind of model
// the catalog has none of: run by the leader, now and then. It adds nothing
// with presets.auto off, where the cluster installs no ComfyUI, or a preset
// somebody declined. It says what it added.
func (a *API) PresetsPass(ctx context.Context) ([]string, error) {
v, err := a.Presets(ctx)
if err != nil || !v.Auto {
return nil, err
}
var added []string
for _, pv := range v.Presets {
if pv.Blocked != "" || pv.InCatalog {
continue
}
p, _ := catalog.PresetNamed(pv.Name)
if err := a.models.Add(ctx, p.Model, store.PresetAdder); err != nil {
return added, fmt.Errorf("adding %s: %w", pv.Name, err)
}
now := time.Now()
if err := a.presets.PresetAdded(ctx, pv.Name, store.PresetAdder, now); err != nil {
return added, err
}
_ = a.presets.WriteAudit(ctx, store.AuditEvent{At: now, Actor: store.PresetAdder, Source: "presets",
Action: "add preset", Target: pv.Name, Status: http.StatusOK,
Detail: fmt.Sprintf("%s, on demand: a node has %s (%s) and the catalog made no %s",
pv.Name, strings.Join(pv.FitsClasses, ", "), pv.Card, pv.Makes)})
added = append(added, pv.Name)
}
return added, nil
}
// presetDeclined records a preset's name removed from the catalog, so the
// leader does not put it back.
func (a *API) presetDeclined(ctx context.Context, name, by string) {
if a.presets == nil {
return
}
if _, ok := catalog.PresetNamed(name); ok {
_ = a.presets.PresetDeclined(ctx, name, by, time.Now())
}
}
// presetBlocked is why the leader would not add a preset by itself, empty
// when it would (or has).
func presetBlocked(p catalog.Preset, v PresetView, cfg PresetConfig, cat *catalog.Catalog) string {
switch {
case v.InCatalog:
return ""
case v.Declined:
return "removed from the catalog after it was added; add it again to undo"
case !cfg.Auto:
return "presets.auto is off"
case cfg.ComfyUI == "":
return "the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that"
case !v.Fits:
return "no node has a card it needs (" + v.Card + ")"
}
for _, m := range cat.Models() {
if p.Makes == catalog.OutputPictures && m.MakesImages() || p.Makes == catalog.OutputMeshes && m.MakesMeshes() {
return "the catalog has " + m.Name + ", which makes " + p.Makes + " already"
}
}
return ""
}
// presetCard is what a preset needs of a card, as people read it.
func presetCard(m catalog.Model) string {
var needs []string
for _, pr := range m.Profiles {
switch {
case pr.GPU != nil:
needs = append(needs, pr.GPU.String())
case pr.GPUClass != "":
needs = append(needs, pr.GPUClass)
}
}
return strings.Join(needs, " or ")
}
// presetFits are the classes of card in the cluster one of the preset's
// profiles can run on.
func presetFits(m catalog.Model, kinds []catalog.GPUKind) []string {
var out []string
for _, k := range kinds {
for _, pr := range m.Profiles {
if pr.GPU != nil && pr.GPU.Fits(k) || pr.GPU == nil && pr.GPUClass == k.Class {
out = append(out, k.Class)
break
}
}
}
return out
}
// gpuKinds are the kinds of card the cluster's nodes have.
func (a *API) gpuKinds() []catalog.GPUKind {
var gpus []discovery.GPU
for _, n := range a.ctl.Nodes() {
gpus = append(gpus, n.GPUs...)
}
return catalog.GPUKinds(gpus)
}
// setComfyUI has the nodes install the ComfyUI this release knows.
func (a *API) setComfyUI(ctx context.Context, by string) error {
if a.settings != nil {
cur, err := a.settings.Store.ClusterSettings(ctx)
if err != nil {
return err
}
if doc, err := settings.FromBytes("settings", []byte(cur.Body)); err == nil {
if n := doc.Node([]string{"engines", catalog.EngineComfyUI}); n != nil && n.Value == "" {
return errors.New("engines.comfyui is set to none, so the nodes install no ComfyUI: " +
"set a version (llmgw ctl config set engines.comfyui " + provision.ComfyUIVersion() + ")")
}
}
}
return a.editSettings(ctx, []string{"engines", catalog.EngineComfyUI}, provision.ComfyUIVersion(), by,
"ComfyUI for the presets")
}
// editSettings sets one key of the cluster's settings, as a new version.
func (a *API) editSettings(ctx context.Context, path []string, value, by, note string) error {
if a.settings == nil {
return errors.New("this control plane keeps no cluster settings")
}
cur, err := a.settings.Store.ClusterSettings(ctx)
if err != nil {
return err
}
doc, err := settings.FromBytes("settings", []byte(cur.Body))
if err != nil {
return err
}
if n := doc.Node(path); n != nil && n.Value == value {
return nil
}
if err := doc.SetYAML(path, value); err != nil {
return err
}
b, err := doc.Bytes()
if err != nil {
return err
}
if err := a.settings.Check(b); err != nil {
return err
}
_, err = a.settings.Store.PutClusterSettings(ctx, cur.Version, string(b), by, note)
return err
}
func (a *API) registerPresets(mux Mux) {
mux.HandleFunc("GET /api/v1/presets", func(w http.ResponseWriter, r *http.Request) {
v, err := a.Presets(r.Context())
if err != nil {
writeError(w, presetStatus(err), err.Error())
return
}
writeJSON(w, http.StatusOK, v)
})
mux.HandleFunc("POST /api/v1/presets/{name}", func(w http.ResponseWriter, r *http.Request) {
if err := a.AddPreset(r.Context(), r.PathValue("name"), actorOf(r)); err != nil {
writeError(w, presetStatus(err), err.Error())
return
}
writeJSON(w, http.StatusOK, ModelAdded{Added: r.PathValue("name")})
})
mux.HandleFunc("PUT /api/v1/presets/auto", func(w http.ResponseWriter, r *http.Request) {
var req PresetsAutoRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if err := a.SetAutoPresets(r.Context(), req.On, actorOf(r)); err != nil {
writeError(w, presetStatus(err), err.Error())
return
}
v, err := a.Presets(r.Context())
if err != nil {
writeError(w, presetStatus(err), err.Error())
return
}
writeJSON(w, http.StatusOK, v)
})
}
// errNoSuchPreset is a name no preset has.
var errNoSuchPreset = errors.New("no preset of that name")
func presetStatus(err error) int {
switch {
case errors.Is(err, errPresetsUnavailable):
return http.StatusNotImplemented
case errors.Is(err, errNoSuchPreset):
return http.StatusNotFound
default:
return http.StatusBadRequest
}
}
+141
View File
@@ -0,0 +1,141 @@
package adminapi
import (
"net/http"
"path/filepath"
"slices"
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/inventory"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
func v100Node(id string) inventory.Node {
return inventory.Node{ID: id, State: inventory.Ready, AgentVersion: "v1", GPUs: []discovery.GPU{{
Index: 0, Name: "Tesla V100-SXM2-32GB", MemoryTotalMB: 32768, ComputeCapability: "7.0"}}}
}
func presetsAPI(t *testing.T, models []catalog.Model, cfg PresetConfig, nodes ...inventory.Node) (*API, *fakeModels, *store.DB) {
t.Helper()
a, _ := setup(t, models, nodes...)
st, err := store.OpenSQLite(t.Context(), filepath.Join(t.TempDir(), "p.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
fm := &fakeModels{}
a.SetModels(fm)
a.SetPresets(st, func() PresetConfig { return cfg })
a.SetSettings(&Settings{Store: st, Check: func([]byte) error { return nil }})
return a, fm, st
}
func TestTheLeaderAddsThePresetsANodeFitsAndNoOtherWhere(t *testing.T) {
// A customer's portal had no pictures or 3D pages: nobody had written a
// definition for either, on a cluster with a V100 that runs both.
a, fm, st := presetsAPI(t, testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"}, v100Node("p-g3"))
added, err := a.PresetsPass(t.Context())
if err != nil || !slices.Equal(added, []string{"flux-schnell", "triposr"}) {
t.Fatalf("added %v %v", added, err)
}
if !slices.Equal(fm.actors, []string{store.PresetAdder, store.PresetAdder}) {
t.Errorf("added by %v", fm.actors)
}
states, _ := st.PresetStates(t.Context())
if states["triposr"].AddedBy != store.PresetAdder || states["triposr"].AddedAt.IsZero() {
t.Errorf("state %+v", states["triposr"])
}
events, _ := st.AuditEvents(t.Context(), store.AuditFilter{Limit: 10})
if len(events) != 2 || events[0].Actor != store.PresetAdder || !strings.Contains(events[0].Detail, "v100-32g") {
t.Errorf("audit %+v", events)
}
}
func TestAPresetIsNotAddedByItselfWhereItShouldNotBe(t *testing.T) {
picture := catalog.Model{Name: "sdxl", Source: "/w/sdxl.safetensors", ComfyUI: &catalog.ComfyUI{Workflow: "flux-schnell"},
Profiles: []catalog.Profile{{Name: "p", Engine: catalog.EngineComfyUI, GPUClass: "v100-32g", TP: 1}}}
cases := []struct {
name string
models []catalog.Model
cfg PresetConfig
nodes []inventory.Node
want map[string]string // preset -> part of why it is not added
}{
{"auto off", testModels(), PresetConfig{Auto: false, ComfyUI: "0.39.2"}, []inventory.Node{v100Node("g")},
map[string]string{"flux-schnell": "presets.auto is off", "triposr": "presets.auto is off"}},
{"no ComfyUI", testModels(), PresetConfig{Auto: true}, []inventory.Node{v100Node("g")},
map[string]string{"triposr": "engines.comfyui"}},
{"no card", testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"}, nil,
map[string]string{"triposr": "no node has a card"}},
{"a picture model already", append(testModels(), picture), PresetConfig{Auto: true, ComfyUI: "0.39.2"},
[]inventory.Node{v100Node("g")}, map[string]string{"flux-schnell": "sdxl"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
a, fm, _ := presetsAPI(t, c.models, c.cfg, c.nodes...)
v, err := a.Presets(t.Context())
if err != nil {
t.Fatal(err)
}
if v.ComfyUI != c.cfg.ComfyUI {
t.Errorf("comfyui %q", v.ComfyUI)
}
for _, p := range v.Presets {
if why, ok := c.want[p.Name]; ok && !strings.Contains(p.Blocked, why) {
t.Errorf("%s blocked %q, want it to say %q", p.Name, p.Blocked, why)
}
}
_, _ = a.PresetsPass(t.Context())
for _, m := range fm.added {
if _, blocked := c.want[m.Name]; blocked {
t.Errorf("%s was added", m.Name)
}
}
})
}
}
func TestAPresetRemovedStaysOutUntilAddedAgain(t *testing.T) {
a, fm, _ := presetsAPI(t, testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"}, v100Node("g"))
if code := call(t, a, http.MethodDelete, "/api/v1/models/triposr", "", nil); code != http.StatusOK {
t.Fatalf("remove %d", code)
}
var v PresetsView
call(t, a, http.MethodGet, "/api/v1/presets", "", &v)
i := slices.IndexFunc(v.Presets, func(p PresetView) bool { return p.Name == "triposr" })
if i < 0 || !v.Presets[i].Declined || !strings.Contains(v.Presets[i].Blocked, "removed") {
t.Fatalf("after removal %+v", v.Presets)
}
added, _ := a.PresetsPass(t.Context())
if slices.Contains(added, "triposr") {
t.Error("a removed preset was added back by itself")
}
if code := call(t, a, http.MethodPost, "/api/v1/presets/triposr", "", nil); code != http.StatusOK {
t.Fatalf("add again %d", code)
}
if fm.added[len(fm.added)-1].Name != "triposr" {
t.Error("adding it again did not add it")
}
call(t, a, http.MethodGet, "/api/v1/presets", "", &v)
i = slices.IndexFunc(v.Presets, func(p PresetView) bool { return p.Name == "triposr" })
if v.Presets[i].Declined {
t.Error("adding it again left it declined")
}
if code := call(t, a, http.MethodPost, "/api/v1/presets/nope", "", nil); code != http.StatusNotFound {
t.Errorf("unknown preset %d", code)
}
}
func TestPresetsAutoIsTheClusterSetting(t *testing.T) {
a, _, st := presetsAPI(t, testModels(), PresetConfig{Auto: true, ComfyUI: "0.39.2"})
if code := call(t, a, http.MethodPut, "/api/v1/presets/auto", `{"on":false}`, nil); code != http.StatusOK {
t.Fatalf("put %d", code)
}
cur, _ := st.ClusterSettings(t.Context())
if !strings.Contains(cur.Body, "auto: false") {
t.Errorf("settings %q", cur.Body)
}
}
+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)
}
}
+23
View File
@@ -42,6 +42,13 @@ type Route struct {
Status int
// CSV is set when the answer is CSV unless JSON is asked for.
CSV bool
// Form is a request sent as multipart/form-data — a file uploaded —
// whose fields are Request's.
Form bool
// Stream is an answer that may come as server-sent events instead.
Stream bool
// Binary is the type of an answer that is a file, not JSON.
Binary string
}
// Param is a query parameter.
@@ -58,10 +65,12 @@ type Param struct {
// before them and their name.
var PathParams = map[string]string{
"models/{name}": "The model's name in the catalog.",
"presets/{name}": "The preset's name, as `llmgw ctl model presets` lists it.",
"nodes/{id}": "The node's ID, as `llmgw ctl nodes` lists it.",
"jobs/{id}": "The job's ID, as starting it answered.",
"runs/{id}": "The quality run's ID, as starting it answered.",
"requests/{id}": "The request's ID: the x-request-id a client was answered with.",
"client-jobs/{id}": "The job's ID, job_….",
"fabrics/{id}": "The physical fabric's ID, as GET /api/v1/fabrics lists it.",
"quotas/{scope}": "The scope: org:NAME, team:ORG/TEAM, project:ORG/TEAM/PROJECT, or their IDs after the colon.",
"federation/{peer}": "The federation peer's name, as `llmgw ctl federation list` lists it.",
@@ -128,6 +137,12 @@ var Routes = []Route{
Request: CollectRequest{}, OptionalBody: true, Response: []CollectResult{}},
// The catalog.
{Method: "GET", Path: "/api/v1/presets", Summary: "The models that come with llmgw: whether each fits this cluster, is in the catalog, was declined",
Response: PresetsView{}},
{Method: "POST", Path: "/api/v1/presets/{name}", Summary: "Add a preset to the catalog, and clear its decline",
Response: ModelAdded{}},
{Method: "PUT", Path: "/api/v1/presets/auto", Summary: "Turn on or off the adding of presets when a node fits them",
Request: PresetsAutoRequest{}, Response: PresetsView{}},
{Method: "GET", Path: "/api/v1/models", Summary: "Every model, with its replicas and state",
Response: []ModelView{}},
{Method: "POST", Path: "/api/v1/models", Summary: "Add a model to the catalog",
@@ -397,6 +412,14 @@ var Routes = []Route{
{Name: "format", Description: "json for JSON; CSV otherwise."},
},
Response: []store.ExportRow{}, CSV: true},
{Method: "GET", Path: "/api/v1/client-jobs", Summary: "Jobs clients asked for — 3D models — of every owner, newest first",
Query: []Param{
{Name: "status", Description: "Only jobs in this status: queued, running, cancelling, completed, failed, cancelled, expired."},
{Name: "limit", Type: "integer", Description: "The most jobs to answer with; 50 when absent."},
},
Response: []JobView{}},
{Method: "GET", Path: "/api/v1/client-jobs/{id}", Summary: "One job a client asked for", Response: JobView{}},
{Method: "POST", Path: "/api/v1/client-jobs/{id}/cancel", Summary: "Stop a job, in the engine's queue too", Response: JobView{}},
{Method: "GET", Path: "/api/v1/requests/{id}", Summary: "What one request went through",
Response: RequestView{}},
{Method: "GET", Path: "/api/v1/requests/{id}/body", Summary: "A kept request and its answer",
+1 -1
View File
@@ -30,7 +30,7 @@ type OrphanView struct {
func (a *API) Orphans() []OrphanView {
var wanted []string
for _, m := range a.ctl.Catalog().Models() {
wanted = append(wanted, m.Source)
wanted = append(wanted, m.WeightSources()...)
// A model's adapters are its weights too; collecting them would
// leave replicas that cannot restart.
for _, ad := range m.Adapters {
+461 -26
View File
@@ -7,7 +7,10 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"maps"
"net/url"
"path"
"regexp"
"slices"
"strings"
"time"
@@ -173,6 +176,12 @@ type Profile struct {
// 0.10.2. Empty takes whatever version the node has.
EngineVersion string `json:"engine_version,omitempty" koanf:"engine_version"`
GPUClass string `json:"gpu_class,omitempty" koanf:"gpu_class"`
// Source is the weights this profile loads, when not the model's: an
// embedding model served by vLLM from its repository on a card and by
// llama.cpp from a GGUF on CPUs is one model with two sources. Empty
// loads the model's. Updates and canaries move the model's source; a
// profile with its own keeps it.
Source string `json:"source,omitempty" koanf:"source"`
TP int `json:"tp,omitempty" koanf:"tp"`
PP int `json:"pp,omitempty" koanf:"pp"`
@@ -270,6 +279,14 @@ type Profile struct {
// which the router's hashing assumes; arguments overriding either leave
// the index empty, and the agent says so in its log.
KVEvents bool `json:"kv_events,omitempty" koanf:"kv_events"`
// GPU says what card the profile needs, in place of naming its class:
// it stands for every class in the cluster that meets it. A catalog
// entry written once then places sensibly on any site's cards.
GPU *GPUNeed `json:"gpu,omitempty" koanf:"gpu"`
// ComfyUI is this profile's own workflow, checkpoint and nodes, over the
// model's: an fp8 workflow where the card has fp8, fp16 where it has not.
ComfyUI *ProfileComfyUI `json:"comfyui,omitempty" koanf:"comfyui"`
}
// CPUOnly reports whether this profile needs no GPUs.
@@ -278,7 +295,7 @@ type Profile struct {
// actually serve without a card are allowed to leave it out — otherwise an
// omitted class would quietly become a replica placed on a machine with no
// hardware to run it.
func (p Profile) CPUOnly() bool { return p.GPUClass == "" && cpuCapable(p.Engine) }
func (p Profile) CPUOnly() bool { return p.GPUClass == "" && p.GPU == nil && cpuCapable(p.Engine) }
// cpuCapable reports whether an engine can serve a model without a GPU.
//
@@ -322,6 +339,13 @@ type Model struct {
// prompts leave the cluster, so which keys may name it is worth an
// allowlist.
External *External `json:"external,omitempty" koanf:"external"`
// ComfyUI is how a model whose profiles run engine comfyui makes its
// pictures: the workflow, and the file it loads.
ComfyUI *ComfyUI `json:"comfyui,omitempty" koanf:"comfyui"`
// License is the terms of what the model makes, for a model whose
// output somebody ships: shown with every result and written into a
// mesh.
License *License `json:"license,omitempty" koanf:"license"`
// Adapters are LoRA adapters served on this model's replicas, each asked
// for by its own name as though it were a model. Every replica loads
// every adapter, applying one only to a request that names it — unless
@@ -385,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.
@@ -462,11 +490,283 @@ type Capabilities struct {
// /v1/images/generations. Implied by an external model whose API is
// ComfyUI's.
Images bool `json:"images,omitempty" koanf:"images"`
// Meshes is a model that makes 3D models from a picture, asked for as a
// job at /v1/3d/generations. Implied by a ComfyUI model whose output is
// meshes.
Meshes bool `json:"meshes,omitempty" koanf:"meshes"`
}
// The kinds of thing a ComfyUI model makes.
const (
OutputPictures = "pictures"
OutputMeshes = "meshes"
)
// comfyOutput is what a ComfyUI model makes: pictures unless it says.
func (m Model) comfyOutput() string {
out := ""
switch {
case m.External != nil:
out = m.External.Output
case m.ComfyUI != nil:
out = m.ComfyUI.Output
}
if out == "" {
return OutputPictures
}
return out
}
// MakesImages reports whether a model answers /v1/images/generations.
func (m Model) MakesImages() bool {
return m.Capabilities.Images || m.External != nil && m.External.API == APIComfyUI
return m.Capabilities.Images || m.ServesComfyUI() && m.comfyOutput() == OutputPictures
}
// MakesMeshes reports whether a model answers /v1/3d/generations.
func (m Model) MakesMeshes() bool {
return m.Capabilities.Meshes || m.ServesComfyUI() && m.comfyOutput() == OutputMeshes
}
// License is the terms what a model makes comes under: said beside every
// result, and written into a mesh, which travels without the API.
type License struct {
// ID names the licence for a machine: an SPDX identifier where there is
// one (MIT, Apache-2.0, CC-BY-NC-4.0), else a lowercase name of the
// licence's own (stability-community, tencent-hunyuan-community). What a
// person's or a site's choice of licences matches on. Name is for people.
ID string `json:"id,omitempty" koanf:"id"`
// Name is the licence's own: MIT, Apache-2.0.
Name string `json:"name" koanf:"name"`
URL string `json:"url,omitempty" koanf:"url"`
// Notice is what must accompany what was made: attribution, terms on
// the output, what the weights were trained on.
Notice string `json:"notice,omitempty" koanf:"notice"`
// Commercial says whether what is made may be used commercially: nil
// is not said, which is not the same as yes.
Commercial *bool `json:"commercial,omitempty" koanf:"commercial"`
// ExcludedRegions are where the licence grants nothing: ISO 3166 codes,
// and EU for the union (Hunyuan's: EU, UK, KR).
ExcludedRegions []string `json:"excluded_regions,omitempty" koanf:"excluded_regions"`
// RevenueCapUSD is the yearly revenue above which the licence asks for
// another agreement (Stability's community licence: 1000000). Zero is
// none.
RevenueCapUSD int64 `json:"revenue_cap_usd,omitempty" koanf:"revenue_cap_usd"`
// Attribution is what a product using the output must show: "Powered by
// Stability AI".
Attribution string `json:"attribution,omitempty" koanf:"attribution"`
}
var (
licenseID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9.+-]{0,63}$`)
regionID = regexp.MustCompile(`^[A-Z]{2}$`)
)
func (l License) validate(model string) error {
if strings.TrimSpace(l.Name) == "" {
return fmt.Errorf("model %q: license.name is required: a licence nobody can name is none", model)
}
if l.ID != "" && !licenseID.MatchString(l.ID) {
return fmt.Errorf("model %q: license.id %q is not an SPDX-style identifier (letters, digits, '.', '+', '-')", model, l.ID)
}
for _, r := range l.ExcludedRegions {
if !regionID.MatchString(r) {
return fmt.Errorf("model %q: license.excluded_regions %q is not an ISO 3166 code in capitals (EU for the union)", model, r)
}
}
if l.RevenueCapUSD < 0 {
return fmt.Errorf("model %q: license.revenue_cap_usd is negative", model)
}
return nil
}
// EngineComfyUI is the engine that runs ComfyUI on a node.
const EngineComfyUI = "comfyui"
// ServesComfyUI reports whether a model is made by ComfyUI, on the
// cluster's own nodes or on a server outside it.
func (m Model) ServesComfyUI() bool {
if m.External != nil {
return m.External.API == APIComfyUI
}
return len(m.Profiles) > 0 && m.Profiles[0].Engine == EngineComfyUI
}
// ComfyUI is a ComfyUI model's workflow and checkpoint.
type ComfyUI struct {
// Workflow is a built-in's name (flux-schnell, the default, or
// flux-dev) or a JSON file on the control plane, in ComfyUI's API
// format.
Workflow string `json:"workflow,omitempty" koanf:"workflow"`
// Checkpoint is the file the workflow's {{checkpoint}} names: the
// source's own name unless set, which a source that is a set of files
// must.
Checkpoint string `json:"checkpoint,omitempty" koanf:"checkpoint"`
// Output is what the workflow makes: pictures (the default), asked for
// at /v1/images/generations; meshes, asked for as a job at
// /v1/3d/generations from a picture; or video, audio or files. Every
// kind is asked for as a job at /v1/workflows/runs too.
Output string `json:"output,omitempty" koanf:"output"`
// Inputs are what the workflow takes from a request at
// /v1/workflows/runs, each the placeholder of its name.
Inputs []Input `json:"inputs,omitempty" koanf:"inputs"`
// Nodes are custom nodes the workflow needs, put in each replica's own
// custom_nodes at the commit named.
Nodes []CustomNode `json:"nodes,omitempty" koanf:"nodes"`
// HuggingFaceRepo is the repository a custom node loads the weights by
// name from (Stable Fast 3D's loader asks for stabilityai/stable-fast-3d):
// the replica's weights are shown to the node there, in the Hugging Face
// cache of the engines' user, so nothing is fetched and no token is
// needed when it runs.
HuggingFaceRepo string `json:"huggingface_repo,omitempty" koanf:"huggingface_repo"`
// Environment names the models that may share one ComfyUI replica: every
// model of an environment agrees on the custom nodes they both name, so
// one replica can carry all of them and ComfyUI moves between their
// weights itself. Empty is an environment of the model alone.
Environment string `json:"environment,omitempty" koanf:"environment"`
}
// CustomNode is a ComfyUI node from a git repository.
type CustomNode struct {
// Repo is the repository, https only.
Repo string `json:"repo" koanf:"repo"`
// Commit is the full commit the node is used at: a branch moves, and a
// node that changed under a model is a model that changed unasked.
Commit string `json:"commit" koanf:"commit"`
// Requirements are the PyPI packages the node needs, by name and version
// range only. They are installed beside the replica, never into
// ComfyUI's environment: a package named here at the range named, and
// every other one at the version ComfyUI already runs. One named here
// is first on ComfyUI's path, so its range must suit ComfyUI too.
Requirements []string `json:"requirements,omitempty" koanf:"requirements"`
// Build are directories of the repository that are Python packages to
// be compiled rather than downloaded: C++ and CUDA extensions no index
// has (Stable Fast 3D's texture_baker and uv_unwrapper). Each is built
// as a wheel with ComfyUI's Python for the card the replica runs on,
// kept on the node for the next replica on the same kind of card, and
// installed beside the replica like its requirements.
Build []string `json:"build,omitempty" koanf:"build"`
}
// NodesEnv carries a ComfyUI model's custom nodes to the agent, which hands
// them to the gate.
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 (
fullCommit = regexp.MustCompile(`^[0-9a-f]{40}$`)
packageSpec = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*(\[[A-Za-z0-9,._-]+\])?\s*([<>=!~]=?\s*[A-Za-z0-9.*+!_-]+\s*,?\s*)*$`)
)
func (n CustomNode) validate(model string) error {
u, err := url.Parse(n.Repo)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
return fmt.Errorf("model %q: custom node %q is not an https repository", model, n.Repo)
}
if !fullCommit.MatchString(n.Commit) {
return fmt.Errorf("model %q: custom node %s needs its full commit (40 hex), not %q: a branch moves",
model, n.Repo, n.Commit)
}
for _, b := range n.Build {
clean := path.Clean(b)
if b == "" || path.IsAbs(b) || clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || strings.ContainsAny(b, "\\ ") {
return fmt.Errorf("model %q: custom node %s build %q is not a directory inside the repository", model, n.Repo, b)
}
}
for _, r := range n.Requirements {
// A package and a version range: an option or a URL would install
// from somewhere other than PyPI.
if !packageSpec.MatchString(strings.TrimSpace(r)) {
return fmt.Errorf("model %q: custom node %s requirement %q is not a package with a version range", model, n.Repo, r)
}
}
return nil
}
// ComfyWorkflow is what a ComfyUI model's replicas are sent: the workflow
// as the catalog names it, and the checkpoint it loads.
func (m Model) ComfyWorkflow() (workflow, checkpoint string) {
if m.External != nil {
return m.External.Workflow, m.External.Model
}
if m.ComfyUI != nil {
workflow, checkpoint = m.ComfyUI.Workflow, m.ComfyUI.Checkpoint
}
if checkpoint == "" && !strings.HasSuffix(m.Source, "/") {
checkpoint = path.Base(strings.TrimPrefix(m.Source, "hf:"))
}
return workflow, checkpoint
}
// checkWorkflow refuses a workflow that is neither a built-in nor a file.
func checkWorkflow(model, w string) error {
// A file is read where the router runs; a name is checked here.
if w != "" && !strings.ContainsAny(w, "/.") && !slices.Contains(comfy.Builtins(), w) {
return fmt.Errorf("model %q: no built-in workflow %q (there are %s; or name a JSON file)",
model, w, strings.Join(comfy.Builtins(), ", "))
}
return nil
}
func (m Model) validateComfyUI() error {
comfyProfiles := 0
for _, p := range m.Profiles {
if p.Engine == EngineComfyUI {
comfyProfiles++
}
}
if comfyProfiles == 0 {
if m.ComfyUI != nil {
return fmt.Errorf("model %q: comfyui: is for a model whose profiles run engine comfyui", m.Name)
}
return nil
}
if comfyProfiles != len(m.Profiles) {
return fmt.Errorf("model %q: every profile runs engine comfyui or none does: "+
"a request for pictures cannot be answered by an engine that writes text", m.Name)
}
if len(m.Adapters) > 0 {
return fmt.Errorf("model %q: ComfyUI serves no adapters", m.Name)
}
var w string
if m.ComfyUI != nil {
w = m.ComfyUI.Workflow
}
if err := checkWorkflow(m.Name, w); err != nil {
return err
}
if m.ComfyUI != nil {
for _, n := range m.ComfyUI.Nodes {
if err := n.validate(m.Name); err != nil {
return err
}
}
}
if err := validOutput(m.Name, m.comfyOutput()); err != nil {
return err
}
if m.ComfyUI != nil && m.ComfyUI.HuggingFaceRepo != "" && !hfRepo.MatchString(m.ComfyUI.HuggingFaceRepo) {
return fmt.Errorf("model %q: comfyui.huggingface_repo %q is not owner/name", m.Name, m.ComfyUI.HuggingFaceRepo)
}
if out := m.comfyOutput(); out != OutputPictures && w == "" {
return fmt.Errorf("model %q: a model that makes %s names its workflow: no built-in makes them", m.Name, out)
}
if err := validateInputs(m.Name, m.ComfyInputs()); err != nil {
return err
}
if _, ckpt := m.ComfyWorkflow(); ckpt == "" {
return fmt.Errorf("model %q: its source is a set of files, so comfyui.checkpoint must name the one the workflow loads",
m.Name)
}
return nil
}
// Catalog is the set of models the cluster should serve.
@@ -487,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
@@ -560,6 +863,25 @@ func New(models []Model) (*Catalog, error) {
}
canaryOf[target.Name] = name
}
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
}
@@ -675,9 +997,15 @@ func (m Model) normalize() Model {
// repository it names: the engine downloads a repository id itself,
// where the page was fetched as one file of HTML and failed at start.
if repo, ok := repositoryPage(m.Source); ok && len(m.Profiles) > 0 &&
!slices.ContainsFunc(m.Profiles, func(p Profile) bool { return p.Engine == "llamacpp" }) {
!slices.ContainsFunc(m.Profiles, func(p Profile) bool { return p.Engine == "llamacpp" && p.Source == "" }) {
m.Source = repo
}
for i := range m.Profiles {
p := &m.Profiles[i]
if repo, ok := repositoryPage(p.Source); ok && p.Engine != "llamacpp" {
p.Source = repo
}
}
return m
}
@@ -688,15 +1016,31 @@ func (m Model) validate() error {
if m.Source == "" {
return fmt.Errorf("model %q: source is required", m.Name)
}
if repo, ok := repositoryPage(m.Source); ok {
// A page, not weights: a node fetches the HTML as one file and the
// engine fails at start, on a path that looks right. For vLLM it was
// read as the repository id when the model was made; only a model
// llama.cpp runs is left here, which needs a file.
if m.License != nil {
if err := m.License.validate(m.Name); err != nil {
return err
}
}
// A page, not weights: a node fetches the HTML as one file and the
// engine fails at start, on a path that looks right. For vLLM it was
// read as the repository id when the model was made; only a source
// llama.cpp loads is left here, which needs a file.
if repo, ok := repositoryPage(m.Source); ok && (len(m.Profiles) == 0 ||
slices.ContainsFunc(m.Profiles, func(p Profile) bool { return p.Engine == "llamacpp" && p.Source == "" })) {
return fmt.Errorf("model %q: source %s is a Hugging Face repository page, not a file; "+
"llama.cpp needs a .gguf: mirror one and use its source (repository %s)",
m.Name, m.Source, repo)
}
for _, p := range m.Profiles {
if repo, ok := repositoryPage(p.Source); ok {
return fmt.Errorf("model %q profile %q: source %s is a Hugging Face repository page, not a file; "+
"llama.cpp needs a .gguf: mirror one and use its source (repository %s)",
m.Name, p.Name, p.Source, repo)
}
if p.Source != "" && m.External != nil {
return fmt.Errorf("model %q profile %q: an external model has no profiles to give a source", m.Name, p.Name)
}
}
if err := validateAdapters(m); err != nil {
return err
}
@@ -707,6 +1051,9 @@ func (m Model) validate() error {
} else if len(m.Profiles) == 0 {
return fmt.Errorf("model %q: at least one profile is required", m.Name)
}
if err := m.validateComfyUI(); err != nil {
return err
}
if m.Owner != "" && !validOwner(m.Owner) {
return fmt.Errorf("model %q: owner %q is not org:ID, team:ID or project:ID", m.Name, m.Owner)
}
@@ -788,10 +1135,18 @@ func (m Model) validate() error {
return fmt.Errorf("model %q profile %q: keep_within: %w", m.Name, p.Name, err)
}
}
if p.GPUClass == "" && !cpuCapable(p.Engine) {
return fmt.Errorf("model %q profile %q: gpu_class is required for engine %q",
if err := p.validateHardware(m); err != nil {
return err
}
if p.GPUClass == "" && p.GPU == nil && !cpuCapable(p.Engine) {
return fmt.Errorf("model %q profile %q: gpu_class is required for engine %q, or gpu: the card it needs",
m.Name, p.Name, p.Engine)
}
if p.GPU != nil {
// Its class is known only when it is placed; what can be checked
// of a class waits for then.
continue
}
if p.CPUOnly() {
if p.Nodes > 1 {
return fmt.Errorf("model %q profile %q: a CPU profile cannot span %d nodes",
@@ -834,7 +1189,7 @@ func (m Model) validate() error {
return fmt.Errorf("model %q profile %q: trtllm runs a replica on one node; "+
"use vllm or sglang for a %d-node replica", m.Name, p.Name, p.Nodes)
}
if strings.HasSuffix(strings.ToLower(strings.TrimSuffix(m.Source, "/")), ".gguf") {
if strings.HasSuffix(strings.ToLower(strings.TrimSuffix(m.SourceOf(p), "/")), ".gguf") {
return fmt.Errorf("model %q profile %q: trtllm loads a Hugging Face checkpoint, not a .gguf; "+
"serve a GGUF with llamacpp", m.Name, p.Name)
}
@@ -902,10 +1257,16 @@ func (c *Catalog) AliasesOf(name string) []string {
return slices.Clone(m.Aliases)
}
// PlacementProfiles converts a model's profiles into placement's view.
// PlacementProfiles converts a model's profiles into placement's view. A
// profile that names its card by need is left out: with no class it would
// read to placement as one that needs no GPU. PlacementProfilesFor gives
// it the classes it stands for.
func (m Model) PlacementProfiles() []placement.Profile {
out := make([]placement.Profile, 0, len(m.Profiles))
for _, p := range m.Profiles {
if p.GPU != nil {
continue
}
out = append(out, placement.Profile{
Name: p.Name,
Engine: p.Engine,
@@ -920,19 +1281,75 @@ func (m Model) PlacementProfiles() []placement.Profile {
CPUCores: p.CPUCores,
PowerWatts: p.PowerWatts,
KeepWithin: facility.Level(strings.ToLower(strings.TrimSpace(p.KeepWithin))),
// The model's source, so placement can prefer nodes that already
// The profile's source, so placement can prefer nodes that already
// hold the weights rather than one that must first move them.
Source: m.Source,
Source: m.SourceOf(p),
})
}
return out
}
// SourceOf is the weights a profile loads: its own, or the model's.
func (m Model) SourceOf(p Profile) string {
if p.Source != "" {
return p.Source
}
return m.Source
}
// WeightSources are every source a model's replicas may load: the model's
// and each profile's of its own, once each. What a node must not have
// collected from under it.
func (m Model) WeightSources() []string {
out := []string{}
if m.Source != "" {
out = append(out, m.Source)
}
for _, p := range m.Profiles {
if p.Source != "" && !slices.Contains(out, p.Source) {
out = append(out, p.Source)
}
}
return out
}
// EngineProfile converts a catalog profile into the engine adapter's view.
func (m Model) EngineProfile(name string) (engines.Profile, bool) {
for _, p := range m.Profiles {
if p.Name != name {
continue
if p, ok := m.ProfileByName(name); ok {
env := p.Env
if nodes := m.ComfyNodesFor(p); p.Engine == EngineComfyUI && len(nodes) > 0 {
nodes, _ := json.Marshal(nodes)
env = maps.Clone(env)
if env == nil {
env = map[string]string{}
}
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 {
env = map[string]string{}
}
env[HFRepoEnv] = m.ComfyUI.HuggingFaceRepo
}
return engines.Profile{
Name: p.Name,
@@ -943,8 +1360,8 @@ func (m Model) EngineProfile(name string) (engines.Profile, bool) {
GPUsPerNode: p.GPUsPerNode,
Nodes: p.Nodes,
Args: slices.Clone(p.Args),
Env: p.Env,
ModelPath: m.Source,
Env: env,
ModelPath: m.SourceOf(p),
ServedModelName: m.Name,
Adapters: m.engineAdapters(),
KVEvents: p.KVEvents,
@@ -965,13 +1382,23 @@ func (m Model) KVIndex() bool {
return false
}
// ProfileByName returns one of a model's profiles.
// ProfileByName returns one of a model's profiles, or one a profile that
// names its GPU by need stands for: "<profile>@<class>", with the class.
func (m Model) ProfileByName(name string) (Profile, bool) {
for _, p := range m.Profiles {
if p.Name == name {
return p, true
}
}
base, class, ok := strings.Cut(name, "@")
if !ok || class == "" {
return Profile{}, false
}
for _, p := range m.Profiles {
if p.Name == base && p.GPU != nil {
return p.forClass(class), true
}
}
return Profile{}, false
}
@@ -1120,6 +1547,8 @@ type External struct {
// (flux-schnell, the default, or flux-dev) or a JSON file on the control
// plane.
Workflow string `json:"workflow,omitempty" koanf:"workflow"`
// Output is what it makes: pictures, or meshes (see ComfyUI.Output).
Output string `json:"output,omitempty" koanf:"output"`
}
// APIComfyUI is External.API for a ComfyUI server.
@@ -1141,17 +1570,23 @@ func (e *External) validate(model string, profiles int) error {
}
switch e.API {
case "", "openai":
if e.Workflow != "" {
return fmt.Errorf("model %q: a workflow is for api: comfyui", model)
if e.Workflow != "" || e.Output != "" {
return fmt.Errorf("model %q: a workflow and an output are for api: comfyui", model)
}
case APIComfyUI:
if e.Peer != "" {
return fmt.Errorf("model %q: a federated model speaks OpenAI's API, not ComfyUI's", model)
}
// A file is read where the router runs; a name is checked here.
if w := e.Workflow; w != "" && !strings.ContainsAny(w, "/.") && !slices.Contains(comfy.Builtins(), w) {
return fmt.Errorf("model %q: no built-in workflow %q (there are %s; or name a JSON file)",
model, w, strings.Join(comfy.Builtins(), ", "))
if err := checkWorkflow(model, e.Workflow); err != nil {
return err
}
if e.Output != "" {
if err := validOutput(model, e.Output); err != nil {
return err
}
}
if e.Output != "" && e.Output != OutputPictures && e.Workflow == "" {
return fmt.Errorf("model %q: a model that makes %s names its workflow: no built-in makes them", model, e.Output)
}
default:
return fmt.Errorf("model %q: external api %q is not openai or comfyui", model, e.API)
+83
View File
@@ -0,0 +1,83 @@
package catalog
import (
"strings"
"testing"
)
func comfyModel(source string, c *ComfyUI, profiles ...Profile) Model {
if len(profiles) == 0 {
profiles = []Profile{{Name: "gpu", Engine: EngineComfyUI, GPUClass: "6000-24g", TP: 1}}
}
return Model{Name: "flux", Source: source, ComfyUI: c, Profiles: profiles}
}
func TestAComfyUIModelMakesPicturesFromItsSourcesFile(t *testing.T) {
cat, err := New([]Model{comfyModel("hf:Comfy-Org/flux1-schnell/flux1-schnell-fp8.safetensors", nil)})
if err != nil {
t.Fatal(err)
}
m, _ := cat.Resolve("flux")
if !m.MakesImages() || !m.ServesComfyUI() {
t.Error("a ComfyUI model does not say it makes pictures")
}
if w, ckpt := m.ComfyWorkflow(); w != "" || ckpt != "flux1-schnell-fp8.safetensors" {
t.Errorf("workflow %q, checkpoint %q", w, ckpt)
}
}
func TestAComfyUIModelIsRefusedWhatItCannotDo(t *testing.T) {
for name, c := range map[string]struct {
m Model
want string
}{
"a set without a checkpoint": {comfyModel("https://mirror/flux-dev/v1/", nil), "comfyui.checkpoint"},
"an unknown workflow": {comfyModel("/w/f.safetensors", &ComfyUI{Workflow: "sdxl"}), "no built-in workflow"},
"mixed engines": {comfyModel("/w/f.safetensors", nil,
Profile{Name: "a", Engine: EngineComfyUI, GPUClass: "6000-24g", TP: 1},
Profile{Name: "b", Engine: "vllm", GPUClass: "6000-24g", TP: 1}), "every profile"},
"comfyui settings without the engine": {Model{Name: "x", Source: "/w/x", ComfyUI: &ComfyUI{},
Profiles: []Profile{{Name: "a", Engine: "vllm", GPUClass: "6000-24g", TP: 1}}}, "engine comfyui"},
"no card": {comfyModel("/w/f.safetensors", nil, Profile{Name: "a", Engine: EngineComfyUI, TP: 1}), "gpu_class"},
} {
if _, err := New([]Model{c.m}); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v", name, err)
}
}
ok := comfyModel("https://mirror/flux-dev/v1/", &ComfyUI{Workflow: "flux-dev", Checkpoint: "flux1-dev-fp8.safetensors"})
if _, err := New([]Model{ok}); err != nil {
t.Errorf("a set with its checkpoint named was refused: %v", err)
}
}
func TestACustomNodeIsPinnedAndItsPackagesArePackages(t *testing.T) {
node := func(n CustomNode) Model {
return comfyModel("hf:stabilityai/TripoSR/model.ckpt", &ComfyUI{Workflow: "w.json", Output: OutputMeshes,
Nodes: []CustomNode{n}})
}
ok := CustomNode{Repo: "https://github.com/flowtyone/ComfyUI-Flowty-TripoSR",
Commit: "0123456789abcdef0123456789abcdef01234567", Requirements: []string{"omegaconf", "trimesh>=4,<5", "scikit-image"}}
cat, err := New([]Model{node(ok)})
if err != nil {
t.Fatal(err)
}
m, _ := cat.Resolve("flux")
p, _ := m.EngineProfile("gpu")
if !strings.Contains(p.Env[NodesEnv], "ComfyUI-Flowty-TripoSR") || !m.MakesMeshes() || m.MakesImages() {
t.Errorf("env %v meshes %v pictures %v", p.Env, m.MakesMeshes(), m.MakesImages())
}
for name, n := range map[string]CustomNode{
"a branch": {Repo: ok.Repo, Commit: "main"},
"http": {Repo: "http://example.com/n", Commit: ok.Commit},
"an option": {Repo: ok.Repo, Commit: ok.Commit, Requirements: []string{"--index-url=https://evil"}},
"a url": {Repo: ok.Repo, Commit: ok.Commit, Requirements: []string{"x @ https://evil/x.whl"}},
} {
if _, err := New([]Model{node(n)}); err == nil {
t.Errorf("%s: accepted", name)
}
}
noWorkflow := comfyModel("hf:a/b/c.ckpt", &ComfyUI{Output: OutputMeshes})
if _, err := New([]Model{noWorkflow}); err == nil || !strings.Contains(err.Error(), "names its workflow") {
t.Errorf("a mesh model without a workflow: %v", err)
}
}
+172
View File
@@ -0,0 +1,172 @@
package catalog
import (
"fmt"
"regexp"
"slices"
"strings"
)
// Environments: the ComfyUI models that may share one replica.
//
// Every ComfyUI model used to run in a replica of its own, so two models on
// one card took turns with a cold start each time: flux, then a mesh model,
// then flux again, each loading ComfyUI and its weights from nothing. ComfyUI
// itself moves between models' weights in one process; what keeps two models
// out of one process is their custom nodes, which share one Python. An
// environment is a set of models whose nodes can: the catalog checks it, and
// placement may then run one replica for all of them.
// Environment is one environment's models and what a replica of it carries.
type Environment struct {
Name string `json:"name"`
// Models are the catalog models in it, in catalog order.
Models []string `json:"models"`
// Nodes are every custom node any of them names, once each.
Nodes []CustomNode `json:"nodes,omitempty"`
// Sources are every weights source its models load, once each: what a
// replica of it must have on its node.
Sources []string `json:"sources,omitempty"`
}
// ComfyEnvironment is the environment a ComfyUI model is in, or "" for one in
// none, which is an environment of its own.
func (m Model) ComfyEnvironment() string {
if m.ComfyUI == nil {
return ""
}
return m.ComfyUI.Environment
}
var envName = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
// Environments are the catalog's named environments, by name.
func (c *Catalog) Environments() []Environment {
envs, _ := c.environments()
return envs
}
// EnvironmentOf is the environment a model is in, when it is in a named one.
func (c *Catalog) EnvironmentOf(model string) (Environment, bool) {
m, ok := c.Resolve(model)
if !ok || m.ComfyEnvironment() == "" {
return Environment{}, false
}
for _, e := range c.Environments() {
if e.Name == m.ComfyEnvironment() {
return e, true
}
}
return Environment{}, false
}
// environments builds the named environments and refuses one whose models
// cannot share a replica: two commits of one node, or two ranges of one
// package, of which one Python can hold only one.
func (c *Catalog) environments() ([]Environment, error) {
byName := map[string]*Environment{}
var order []string
// Who brought each node and each package into each environment, to name
// both models when two disagree.
nodeFrom := map[string]map[string]string{} // env -> repo -> model
nodeAt := map[string]map[string]string{} // env -> repo -> commit
pkgFrom := map[string]map[string]string{} // env -> package -> model
pkgSpec := map[string]map[string]string{} // env -> package -> spec
for _, name := range c.order {
m := c.models[name]
env := m.ComfyEnvironment()
if env == "" || m.IsEnvironment() {
continue
}
if !envName.MatchString(env) {
return nil, fmt.Errorf("model %q: comfyui.environment %q is not a name of lowercase letters, digits, '.', '_' and '-'",
name, env)
}
if !m.ServesComfyUI() || m.External != nil {
return nil, fmt.Errorf("model %q: comfyui.environment is for a model the cluster's ComfyUI serves", name)
}
e := byName[env]
if e == nil {
e = &Environment{Name: env}
byName[env] = e
order = append(order, env)
nodeFrom[env], nodeAt[env] = map[string]string{}, map[string]string{}
pkgFrom[env], pkgSpec[env] = map[string]string{}, map[string]string{}
}
e.Models = append(e.Models, name)
for _, src := range m.WeightSources() {
if !slices.Contains(e.Sources, src) {
e.Sources = append(e.Sources, src)
}
}
for _, n := range m.ComfyUI.Nodes {
repo := strings.TrimSuffix(strings.TrimRight(n.Repo, "/"), ".git")
if at, seen := nodeAt[env][repo]; seen {
if at != n.Commit {
return nil, fmt.Errorf("environment %q: models %q and %q name custom node %s at different commits (%s, %s): "+
"one replica holds one of them", env, nodeFrom[env][repo], name, n.Repo, short(at), short(n.Commit))
}
} else {
nodeAt[env][repo], nodeFrom[env][repo] = n.Commit, name
e.Nodes = append(e.Nodes, CustomNode{Repo: n.Repo, Commit: n.Commit})
}
i := slices.IndexFunc(e.Nodes, func(x CustomNode) bool {
return strings.TrimSuffix(strings.TrimRight(x.Repo, "/"), ".git") == repo
})
for _, b := range n.Build {
if !slices.Contains(e.Nodes[i].Build, b) {
e.Nodes[i].Build = append(e.Nodes[i].Build, b)
}
}
for _, r := range n.Requirements {
pkg, spec := requirementName(r), normalizeSpec(r)
if prev, seen := pkgSpec[env][pkg]; seen {
if prev != spec {
return nil, fmt.Errorf("environment %q: models %q and %q ask for %s as %q and %q: "+
"one Python holds one version; name the same range in both",
env, pkgFrom[env][pkg], name, pkg, prev, spec)
}
continue
}
pkgSpec[env][pkg], pkgFrom[env][pkg] = spec, name
// The requirement goes with the node that first named it:
// what the replica installs is the union, and each once.
e.Nodes[i].Requirements = append(e.Nodes[i].Requirements, strings.TrimSpace(r))
}
}
}
out := make([]Environment, 0, len(order))
for _, n := range order {
out = append(out, *byName[n])
}
return out, nil
}
// requirementName is a requirement's package, as PyPI compares names.
func requirementName(spec string) string {
s := strings.TrimSpace(spec)
if i := strings.IndexAny(s, "<>=!~;[ "); i >= 0 {
s = s[:i]
}
return strings.NewReplacer("_", "-", ".", "-").Replace(strings.ToLower(s))
}
// normalizeSpec is a requirement without its spaces and with its package
// named as PyPI compares it, so "transformers >=4.50, <5" and
// "Transformers>=4.50,<5" are one range.
func normalizeSpec(spec string) string {
s := strings.Join(strings.Fields(spec), "")
name := requirementName(s)
i := strings.IndexAny(s, "<>=!~;[")
if i < 0 {
return name
}
return name + s[i:]
}
func short(commit string) string {
if len(commit) > 12 {
return commit[:12]
}
return commit
}
@@ -0,0 +1,114 @@
package catalog
import (
"strings"
"testing"
)
const (
commitA = "0123456789abcdef0123456789abcdef01234567"
commitB = "fedcba9876543210fedcba9876543210fedcba98"
)
func inEnv(name, env, source string, nodes ...CustomNode) Model {
m := comfyModel(source, &ComfyUI{Workflow: "w.json", Output: OutputMeshes, Environment: env, Nodes: nodes})
m.Name = name
return m
}
func TestModelsInOneEnvironmentShareTheirNodesAndWeights(t *testing.T) {
sf3d := CustomNode{Repo: "https://github.com/Stability-AI/stable-fast-3d", Commit: commitA,
Requirements: []string{"trimesh", "transformers>=4.42,<5"}}
tripo := CustomNode{Repo: "https://github.com/flowtyone/ComfyUI-Flowty-TripoSR", Commit: commitB,
Requirements: []string{"Transformers >= 4.42, <5", "rembg"}}
flux := comfyModel("/w/flux.safetensors", &ComfyUI{Environment: "shared"})
cat, err := New([]Model{
flux,
inEnv("sf3d", "shared", "/w/sf3d/model.safetensors", sf3d),
inEnv("triposr", "shared", "/w/tripo/model.ckpt", tripo),
inEnv("alone", "", "/w/alone.ckpt"),
})
if err != nil {
t.Fatal(err)
}
envs := cat.Environments()
if len(envs) != 1 {
t.Fatalf("environments %+v", envs)
}
e := envs[0]
if strings.Join(e.Models, ",") != "flux,sf3d,triposr" {
t.Errorf("models %v", e.Models)
}
if len(e.Sources) != 3 || len(e.Nodes) != 2 {
t.Errorf("sources %v nodes %+v", e.Sources, e.Nodes)
}
// transformers once, with the node that named it first; rembg with its own.
if got := strings.Join(e.Nodes[0].Requirements, " "); got != "trimesh transformers>=4.42,<5" {
t.Errorf("first node's requirements %q", got)
}
if got := strings.Join(e.Nodes[1].Requirements, " "); got != "rembg" {
t.Errorf("second node's requirements %q", got)
}
if got, ok := cat.EnvironmentOf("sf3d"); !ok || got.Name != "shared" {
t.Errorf("sf3d's environment %+v %v", got, ok)
}
if _, ok := cat.EnvironmentOf("alone"); ok {
t.Error("a model with no environment is in one")
}
}
func TestAnEnvironmentWhoseModelsDisagreeIsRefused(t *testing.T) {
node := func(commit string, reqs ...string) CustomNode {
return CustomNode{Repo: "https://github.com/a/node", Commit: commit, Requirements: reqs}
}
for name, c := range map[string]struct {
models []Model
want string
}{
"two commits of one node": {[]Model{
inEnv("a", "e", "/w/a", node(commitA)), inEnv("b", "e", "/w/b", node(commitB))}, "different commits"},
"two ranges of one package": {[]Model{
inEnv("a", "e", "/w/a", node(commitA, "numpy<2")),
inEnv("b", "e", "/w/b", CustomNode{Repo: "https://github.com/b/other", Commit: commitB,
Requirements: []string{"numpy>=2"}})}, `"a" and "b" ask for numpy`},
"a bad name": {[]Model{inEnv("a", "Shared Env", "/w/a")}, "comfyui.environment"},
} {
if _, err := New(c.models); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v", name, err)
}
}
// The same node at the same commit, the same range written two ways: fine.
if _, err := New([]Model{
inEnv("a", "e", "/w/a", node(commitA, "numpy >= 2")),
inEnv("b", "e", "/w/b", node(commitA, "NumPy>=2")),
}); err != nil {
t.Errorf("agreeing models refused: %v", err)
}
}
func TestALicenceIsSaidSoAMachineCanMatchIt(t *testing.T) {
yes := true
ok := License{ID: "tencent-hunyuan-community", Name: "Tencent Hunyuan Community License", Commercial: &yes,
ExcludedRegions: []string{"EU", "GB", "KR"}}
m := comfyModel("/w/f.safetensors", nil)
m.License = &ok
if _, err := New([]Model{m}); err != nil {
t.Fatalf("a full licence refused: %v", err)
}
for name, c := range map[string]struct {
l License
want string
}{
"no name": {License{ID: "MIT"}, "license.name"},
"a sentence id": {License{ID: "the MIT one", Name: "MIT"}, "license.id"},
"a lowercase eu": {License{Name: "x", ExcludedRegions: []string{"eu"}}, "excluded_regions"},
"a negative cap": {License{Name: "x", RevenueCapUSD: -1}, "revenue_cap_usd"},
} {
m := comfyModel("/w/f.safetensors", nil)
l := c.l
m.License = &l
if _, err := New([]Model{m}); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v", name, err)
}
}
}
+188
View File
@@ -0,0 +1,188 @@
package catalog
import (
"fmt"
"slices"
"strconv"
"strings"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
"gitea.mynodes.xyz/ilker/llmgw/internal/control/placement"
)
// Profiles that fit the hardware.
//
// A profile names its GPU class — 6000-24g, a100-80g — and placement matches
// it exactly. A catalog entry meant for many sites cannot know their class
// names: the PoC has one Turing card, a customer an A100, another an L40S. So
// a profile may say what card it needs instead — a compute capability, a
// memory — and stands for every class in the cluster that meets it. What
// differs between cards goes on the profile too: its own weights (Source),
// its own workflow and nodes (ComfyUI), its own engine arguments. The model
// then has, say, an fp8 profile for compute 8.9 and up at a higher priority
// and an fp16 one for anything with 16 GB, and each site gets the best its
// cards can run.
// GPUNeed is what card a profile needs.
type GPUNeed struct {
// MinComputeCapability is the oldest architecture that runs it: 7.5 is
// Turing, 8.0 Ampere (bf16, flash-attention 2), 8.9 Ada (fp8), 9.0 Hopper.
MinComputeCapability float64 `json:"min_compute_capability,omitempty" koanf:"min_compute_capability"`
// MinMemoryGB is the card's own memory it needs.
MinMemoryGB int `json:"min_memory_gb,omitempty" koanf:"min_memory_gb"`
}
// ProfileComfyUI is what a ComfyUI profile runs differently from the model:
// each field set replaces the model's.
type ProfileComfyUI struct {
Workflow string `json:"workflow,omitempty" koanf:"workflow"`
Checkpoint string `json:"checkpoint,omitempty" koanf:"checkpoint"`
Nodes []CustomNode `json:"nodes,omitempty" koanf:"nodes"`
}
// GPUKind is one kind of card in the cluster.
type GPUKind struct {
Class string
ComputeCapability float64
MemoryGB int
}
// GPUKinds are the kinds of card among gpus, one each, by class.
func GPUKinds(gpus []discovery.GPU) []GPUKind {
seen := map[string]bool{}
var out []GPUKind
for _, g := range gpus {
c := g.Class()
if seen[c] {
continue
}
seen[c] = true
cc, _ := strconv.ParseFloat(strings.TrimSpace(g.ComputeCapability), 64)
// A MIG instance's memory is the instance's: what a process sees.
out = append(out, GPUKind{Class: c, ComputeCapability: cc, MemoryGB: (g.MemoryTotalMB + 512) / 1024})
}
slices.SortFunc(out, func(a, b GPUKind) int { return strings.Compare(a.Class, b.Class) })
return out
}
// Fits reports whether a kind of card meets the need.
func (n GPUNeed) Fits(k GPUKind) bool {
// A card that does not say its capability is not taken for one that has
// it: an fp8 workflow on a card of unknown age fails at its first step.
if n.MinComputeCapability > 0 && k.ComputeCapability+1e-9 < n.MinComputeCapability {
return false
}
return n.MinMemoryGB <= 0 || k.MemoryGB >= n.MinMemoryGB
}
func (n GPUNeed) String() string {
var parts []string
if n.MinComputeCapability > 0 {
parts = append(parts, fmt.Sprintf("compute %.1f+", n.MinComputeCapability))
}
if n.MinMemoryGB > 0 {
parts = append(parts, fmt.Sprintf("%d GB+", n.MinMemoryGB))
}
if len(parts) == 0 {
return "any GPU"
}
return strings.Join(parts, ", ")
}
// forClass is a profile that names its GPU by need, as the one it stands for
// on a class.
func (p Profile) forClass(class string) Profile {
p.Name = p.Name + "@" + class
p.GPUClass = class
p.GPU = nil
return p
}
func (p Profile) validateHardware(m Model) error {
if strings.Contains(p.Name, "@") {
return fmt.Errorf("model %q profile %q: a profile's name has no '@': it is how one standing for a class is named",
m.Name, p.Name)
}
if p.GPU != nil {
if p.GPUClass != "" {
return fmt.Errorf("model %q profile %q: gpu_class and gpu are one or the other: a class, or what card it needs",
m.Name, p.Name)
}
if p.GPU.MinComputeCapability < 0 || p.GPU.MinComputeCapability > 20 || p.GPU.MinMemoryGB < 0 {
return fmt.Errorf("model %q profile %q: gpu %+v is not a compute capability and a memory", m.Name, p.Name, *p.GPU)
}
}
if p.ComfyUI != nil {
if p.Engine != EngineComfyUI {
return fmt.Errorf("model %q profile %q: comfyui: is for a profile of engine comfyui", m.Name, p.Name)
}
if err := checkWorkflow(m.Name, p.ComfyUI.Workflow); err != nil {
return err
}
for _, n := range p.ComfyUI.Nodes {
if err := n.validate(m.Name); err != nil {
return err
}
}
}
return nil
}
// PlacementProfilesFor is a model's profiles in placement's view, each one
// that names its GPU by need standing for every kind of card among kinds
// that meets it, at its own priority. One that no card meets stands for
// none, and is not tried.
func (m Model) PlacementProfilesFor(kinds []GPUKind) []placement.Profile {
var expanded []Profile
for _, p := range m.Profiles {
if p.GPU == nil {
expanded = append(expanded, p)
continue
}
for _, k := range kinds {
if p.GPU.Fits(k) {
expanded = append(expanded, p.forClass(k.Class))
}
}
}
mm := m
mm.Profiles = expanded
return mm.PlacementProfiles()
}
// ComfyWorkflowFor is what a profile's replicas are sent: the profile's own
// workflow and checkpoint where it has them, else the model's.
func (m Model) ComfyWorkflowFor(p Profile) (workflow, checkpoint string) {
workflow, checkpoint = m.ComfyWorkflow()
if p.ComfyUI != nil {
if p.ComfyUI.Workflow != "" {
workflow = p.ComfyUI.Workflow
}
if p.ComfyUI.Checkpoint != "" {
checkpoint = p.ComfyUI.Checkpoint
}
}
if p.Source != "" && (p.ComfyUI == nil || p.ComfyUI.Checkpoint == "") && !strings.HasSuffix(p.Source, "/") {
// A profile with weights of its own loads them by their own name.
checkpoint = lastPathPart(strings.TrimPrefix(p.Source, "hf:"))
}
return workflow, checkpoint
}
// ComfyNodesFor are the custom nodes a profile's replicas carry.
func (m Model) ComfyNodesFor(p Profile) []CustomNode {
if p.ComfyUI != nil && p.ComfyUI.Nodes != nil {
return p.ComfyUI.Nodes
}
if m.ComfyUI != nil {
return m.ComfyUI.Nodes
}
return nil
}
func lastPathPart(s string) string {
if i := strings.LastIndex(s, "/"); i >= 0 {
return s[i+1:]
}
return s
}
+103
View File
@@ -0,0 +1,103 @@
package catalog
import (
"strings"
"testing"
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
)
// sf3dByCard is one entry for every site: fp16 on anything with 16 GB, and
// a bf16 workflow with its own weights where the card is Ampere or newer.
func sf3dByCard() Model {
return Model{Name: "sf3d", Source: "/w/sf3d/model.safetensors",
ComfyUI: &ComfyUI{Workflow: "/etc/llmgw/workflows/sf3d.json", Output: OutputMeshes},
Profiles: []Profile{
{Name: "ampere", Engine: EngineComfyUI, TP: 1, Priority: 10, GPU: &GPUNeed{MinComputeCapability: 8.0, MinMemoryGB: 16},
Source: "/w/sf3d-bf16/model-bf16.safetensors",
ComfyUI: &ProfileComfyUI{Workflow: "/etc/llmgw/workflows/sf3d-bf16.json"}},
{Name: "any", Engine: EngineComfyUI, TP: 1, GPU: &GPUNeed{MinMemoryGB: 16}},
}}
}
func TestAProfileThatNamesItsCardByNeedStandsForEveryClassThatMeetsIt(t *testing.T) {
cat, err := New([]Model{sf3dByCard()})
if err != nil {
t.Fatal(err)
}
m, _ := cat.Resolve("sf3d")
turing := GPUKinds([]discovery.GPU{{Name: "Quadro RTX 6000", MemoryTotalMB: 24576, ComputeCapability: "7.5"}})
if len(turing) != 1 || turing[0].Class != "6000-24g" || turing[0].ComputeCapability != 7.5 || turing[0].MemoryGB != 24 {
t.Fatalf("kinds %+v", turing)
}
ps := m.PlacementProfilesFor(turing)
if len(ps) != 1 || ps[0].Name != "any@6000-24g" || ps[0].GPUClass != "6000-24g" {
t.Fatalf("on Turing: %+v", ps)
}
both := GPUKinds([]discovery.GPU{
{Name: "Quadro RTX 6000", MemoryTotalMB: 24576, ComputeCapability: "7.5"},
{Name: "NVIDIA A100-SXM4-80GB", MemoryTotalMB: 81920, ComputeCapability: "8.0"},
{Name: "NVIDIA A100-SXM4-80GB", MemoryTotalMB: 81920, ComputeCapability: "8.0"},
{Name: "Tesla T4", MemoryTotalMB: 15360, ComputeCapability: "7.5"},
})
var names []string
for _, p := range m.PlacementProfilesFor(both) {
names = append(names, p.Name+"/"+p.GPUClass)
}
if got := strings.Join(names, " "); got != "ampere@a100-80g/a100-80g any@6000-24g/6000-24g any@a100-80g/a100-80g" {
t.Errorf("on a mixed cluster: %s (the T4 has 15 GB, short of 16)", got)
}
if len(m.PlacementProfiles()) != 0 {
t.Error("with no cards known, a profile by need stood for something")
}
// A replica placed as ampere@a100-80g is started with that profile's own
// weights, workflow and class.
p, ok := m.ProfileByName("ampere@a100-80g")
if !ok || p.GPUClass != "a100-80g" || p.GPU != nil || m.SourceOf(p) != "/w/sf3d-bf16/model-bf16.safetensors" {
t.Fatalf("profile %+v %v", p, ok)
}
if w, ckpt := m.ComfyWorkflowFor(p); w != "/etc/llmgw/workflows/sf3d-bf16.json" || ckpt != "model-bf16.safetensors" {
t.Errorf("workflow %q checkpoint %q", w, ckpt)
}
if ep, ok := m.EngineProfile("ampere@a100-80g"); !ok || ep.GPUClass != "a100-80g" || ep.ModelPath != "/w/sf3d-bf16/model-bf16.safetensors" {
t.Errorf("engine profile %+v", ep)
}
if w, ckpt := m.ComfyWorkflowFor(m.Profiles[1]); w != "/etc/llmgw/workflows/sf3d.json" || ckpt != "model.safetensors" {
t.Errorf("the fallback's workflow %q checkpoint %q", w, ckpt)
}
if _, ok := m.ProfileByName("nothing@a100-80g"); ok {
t.Error("a profile that is not there was found")
}
}
func TestAProfileByNeedIsCheckedLikeAnyOther(t *testing.T) {
for name, c := range map[string]struct {
p Profile
want string
}{
"both": {Profile{Name: "p", Engine: EngineComfyUI, TP: 1, GPUClass: "a100-80g", GPU: &GPUNeed{MinMemoryGB: 8}}, "one or the other"},
"an @ in a name": {Profile{Name: "p@x", Engine: EngineComfyUI, TP: 1, GPUClass: "a100-80g"}, "no '@'"},
"a capability of 80": {Profile{Name: "p", Engine: EngineComfyUI, TP: 1, GPU: &GPUNeed{MinComputeCapability: 80}},
"compute capability"},
"comfyui on vllm": {Profile{Name: "p", Engine: "vllm", TP: 1, GPU: &GPUNeed{}, ComfyUI: &ProfileComfyUI{}},
"engine comfyui"},
} {
m := sf3dByCard()
m.Profiles = []Profile{c.p}
if c.p.Engine == "vllm" {
m.ComfyUI = nil
}
if _, err := New([]Model{m}); err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("%s: %v", name, err)
}
}
need := GPUNeed{MinComputeCapability: 8.9}
if need.Fits(GPUKind{Class: "x", MemoryGB: 80}) {
t.Error("a card that does not say its capability was taken for an Ada")
}
if !need.Fits(GPUKind{ComputeCapability: 9.0}) || need.String() != "compute 8.9+" {
t.Error("a Hopper is not an Ada or newer")
}
}
+99
View File
@@ -0,0 +1,99 @@
package catalog
import (
"slices"
"time"
)
// Preset is a catalog entry that comes with llmgw: a model whose workflow,
// custom nodes, packages and licence somebody has already found and tried,
// so that adding it is naming it.
//
// A ComfyUI model cannot be worked out from its repository the way a GGUF
// can: which workflow runs it, which node at which commit, which packages at
// which ranges, and under which licence what it makes is shipped are learned
// by running it. Written in a document, they were copied by hand on every
// site, and a line left out — the node that cuts the picture out — gave
// meshes with their background as geometry.
type Preset struct {
// Makes is what it makes: pictures or meshes.
Makes string
Model Model
}
// Presets are the entries that come with llmgw, by name. Each profile says
// the card it needs rather than naming a class, so the entry places on any
// site's cards.
func Presets() []Preset {
yes := true
return []Preset{
{
Makes: OutputPictures,
Model: Model{
Name: "flux-schnell",
Description: "FLUX.1 [schnell]: pictures from a prompt, in four steps",
Source: "hf:Comfy-Org/flux1-schnell/flux1-schnell-fp8.safetensors",
ComfyUI: &ComfyUI{Workflow: "flux-schnell"},
License: &License{
ID: "Apache-2.0", Name: "Apache-2.0", Commercial: &yes,
URL: "https://huggingface.co/black-forest-labs/FLUX.1-schnell",
Notice: "Made with FLUX.1 [schnell] (Black Forest Labs, Apache 2.0).",
},
Scaling: Scaling{Min: 0, Max: 1, IdleTimeout: 15 * time.Minute},
// The checkpoint is 17 GB with its text encoders; a card with
// no fp8 has its weights upcast as they load.
Profiles: []Profile{{Name: "gpu", Engine: EngineComfyUI, TP: 1,
GPU: &GPUNeed{MinComputeCapability: 7.0, MinMemoryGB: 24}}},
},
},
{
Makes: OutputMeshes,
Model: Model{
Name: "triposr",
Description: "TripoSR: a 3D model (GLB) from a picture of one object on a plain background, as a job",
Source: "hf:stabilityai/TripoSR/model.ckpt",
ComfyUI: &ComfyUI{
Workflow: "triposr",
Output: OutputMeshes,
Nodes: []CustomNode{{
Repo: "https://github.com/flowtyone/ComfyUI-Flowty-TripoSR",
Commit: "a0c94ac60a7cc062604f61aeeea6d0d493521de3",
// transformers 5 renames the checkpoint's keys; rembg
// and onnxruntime cut out a picture with no alpha.
Requirements: []string{"omegaconf", "trimesh", "scikit-image", "imageio",
"transformers>=4.50.3,<5", "huggingface-hub<1", "tokenizers", "rembg", "onnxruntime"},
}},
},
License: &License{
ID: "MIT", Name: "MIT", Commercial: &yes,
URL: "https://huggingface.co/stabilityai/TripoSR/blob/main/LICENSE",
Notice: "Made with TripoSR (Stability AI and Tripo AI, MIT). Its image encoder is " +
"facebook/dino-vitb16 (Apache 2.0). TripoSR was trained on a filtered subset of " +
"Objaverse, some of whose objects carry their own licences.",
},
Scaling: Scaling{Min: 0, Max: 1, IdleTimeout: 10 * time.Minute},
Profiles: []Profile{{Name: "gpu", Engine: EngineComfyUI, TP: 1,
GPU: &GPUNeed{MinComputeCapability: 7.0, MinMemoryGB: 8}}},
},
},
}
}
// PresetNamed is the preset of that name.
func PresetNamed(name string) (Preset, bool) {
ps := Presets()
i := slices.IndexFunc(ps, func(p Preset) bool { return p.Model.Name == name })
if i < 0 {
return Preset{}, false
}
return ps[i], true
}
// PresetNames are the presets' names, in order.
func PresetNames() []string {
var out []string
for _, p := range Presets() {
out = append(out, p.Model.Name)
}
return out
}
Loaded 100 of 262 files, more files were not shown because too many files have changed in this diff. Show more