Compare commits
31
Commits
No files matched your search
+163
-1
@@ -1126,7 +1126,8 @@ 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), SAM 3D Objects (Meta's own licence,
|
||||
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
|
||||
@@ -1201,6 +1202,23 @@ 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
|
||||
@@ -1252,6 +1270,150 @@ 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)
|
||||
- `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) — the other session's (llmgw-b7), at the user's request
|
||||
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)
|
||||
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.
|
||||
|
||||
## D. Facts worth knowing before starting
|
||||
|
||||
- Admin API: `127.0.0.1:9090`, no auth, `ctlClient` in `internal/cli/ctl.go`.
|
||||
|
||||
+128
-1
@@ -19,13 +19,140 @@ is only released once its **exit criterion** is met, not when its code lands.
|
||||
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
|
||||
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs) |
|
||||
| v1.0.0 | 5 | Tenancy, prefix affinity, benchmarking | planned |
|
||||
| v1.0.0 | 5 | What phase 5 has left: precise KV routing proven on vLLM, the block cost picker, defragmentation across NVLink domains, prefill/decode disaggregation | planned |
|
||||
|
||||
Until v1.0.0 the minor version is the breaking one: configuration formats and Go
|
||||
APIs change between minors without a deprecation period.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **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.
|
||||
|
||||
## 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,
|
||||
|
||||
@@ -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; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs) |
|
||||
| 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) |
|
||||
| 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,28 @@ 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)):
|
||||
|
||||
- **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
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
+321
-2
@@ -86,6 +86,9 @@
|
||||
{
|
||||
"name": "plan"
|
||||
},
|
||||
{
|
||||
"name": "presets"
|
||||
},
|
||||
{
|
||||
"name": "projects"
|
||||
},
|
||||
@@ -3651,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",
|
||||
@@ -5203,7 +5316,7 @@
|
||||
}
|
||||
},
|
||||
"Unauthorized": {
|
||||
"description": "The admin token is missing or wrong.",
|
||||
"description": "The token is missing or wrong.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
@@ -7481,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.",
|
||||
@@ -8774,7 +8966,14 @@
|
||||
},
|
||||
"output": {
|
||||
"type": "string",
|
||||
"description": "Output is what the workflow makes: pictures (the default), asked for at /v1/images/generations, or meshes, asked for as a job at /v1/3d/generations from a picture."
|
||||
"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",
|
||||
@@ -8782,6 +8981,14 @@
|
||||
"$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."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -8803,6 +9010,13 @@
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -8858,6 +9072,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
},
|
||||
"catalog.GuardModel": {
|
||||
"type": "object",
|
||||
"description": "GuardModel is how a judge model's answers are read.",
|
||||
@@ -8875,10 +9103,55 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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."
|
||||
@@ -8889,6 +9162,26 @@
|
||||
"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\"."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9151,6 +9444,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+22
-1
@@ -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,19 @@ curl https://llm.example.com/mcp \
|
||||
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
|
||||
```
|
||||
|
||||
### 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,7 +566,7 @@ 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 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. |
|
||||
|
||||
+63
-9
@@ -1300,6 +1300,37 @@ 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:
|
||||
|
||||
@@ -1312,8 +1343,10 @@ 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), or from PyTorch's CUDA 12.9 build where the driver is older
|
||||
than 580. The install is checked on the node's GPU, and takes about 10 GB.
|
||||
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
|
||||
@@ -2072,7 +2105,13 @@ curl -X POST http://gateway:8080/v1/3d/generations/job_…/cancel -H "Authorizat
|
||||
- **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.
|
||||
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
|
||||
@@ -2080,7 +2119,10 @@ curl -X POST http://gateway:8080/v1/3d/generations/job_…/cancel -H "Authorizat
|
||||
- **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. It is one usage record under the job's id, tagged
|
||||
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
|
||||
@@ -2105,38 +2147,50 @@ 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:
|
||||
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: /etc/llmgw/workflows/triposr.json # on every control plane
|
||||
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]
|
||||
"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, gpu_class: 6000-24g, tp: 1}
|
||||
- {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": ["1", 0],
|
||||
"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
|
||||
|
||||
+231
-2
@@ -65,7 +65,10 @@ rather than repeated.
|
||||
| `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) |
|
||||
@@ -82,6 +85,7 @@ rather than repeated.
|
||||
| `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) |
|
||||
@@ -117,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) |
|
||||
@@ -133,9 +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) |
|
||||
@@ -224,6 +244,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) |
|
||||
@@ -248,6 +269,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) |
|
||||
@@ -319,6 +345,7 @@ rather than repeated.
|
||||
| `mail` | `mail.Config` | [Server](#server) |
|
||||
| `serving` | `ServingSettings` | [Server](#server) |
|
||||
| `firewall` | `FirewallSettings` | [Server](#server) |
|
||||
| `presets` | `PresetSettings` | [Server](#server) |
|
||||
| `agents` | `AgentSettings` | [Server](#server) |
|
||||
| `firewall_networks` | `[]string` | [Server](#server) |
|
||||
| `backup_recipients` | `[]string` | [Server](#server) |
|
||||
@@ -783,8 +810,16 @@ must.
|
||||
`string`
|
||||
|
||||
Output is what the workflow makes: pictures (the default), asked for
|
||||
at /v1/images/generations, or meshes, asked for as a job at
|
||||
/v1/3d/generations from a picture.
|
||||
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`
|
||||
|
||||
@@ -793,6 +828,25 @@ at /v1/images/generations, or meshes, asked for as a job at
|
||||
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`
|
||||
@@ -918,6 +972,17 @@ 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
|
||||
|
||||
### `id`
|
||||
@@ -1167,6 +1232,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`
|
||||
@@ -1186,6 +1266,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`
|
||||
@@ -1299,6 +1435,15 @@ 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`
|
||||
@@ -1316,6 +1461,35 @@ Name is the licence's own: MIT, Apache-2.0.
|
||||
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`
|
||||
@@ -1986,6 +2160,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`
|
||||
@@ -2179,6 +2367,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`
|
||||
@@ -2755,6 +2976,14 @@ 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.
|
||||
|
||||
### `agents`
|
||||
|
||||
`AgentSettings`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,10 +124,15 @@ func (c *ComfyUI) Commands(s ShardSpec) ([]Command, error) {
|
||||
if s.APIKey != "" {
|
||||
env["LLMGW_ENGINE_KEY"] = s.APIKey
|
||||
}
|
||||
// A model's custom nodes, which only the catalog sets — a profile's env
|
||||
// may not name the variable — and so taken before baseEnv drops it.
|
||||
if nodes := p.Env[comfyNodesEnv]; nodes != "" {
|
||||
env[comfyNodesEnv] = nodes
|
||||
// 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} {
|
||||
if v := p.Env[k]; v != "" {
|
||||
env[k] = v
|
||||
}
|
||||
}
|
||||
return []Command{{Name: "comfyui", Path: bin, Args: args, Env: materializeEnv(env)}}, nil
|
||||
}
|
||||
@@ -135,3 +140,7 @@ func (c *ComfyUI) Commands(s ShardSpec) ([]Command, error) {
|
||||
// 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"
|
||||
@@ -33,6 +33,25 @@ func TestComfyUIRunsBehindTheGateWithItsKeyOutOfSight(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -91,6 +91,25 @@ func TestComfyUIOnAnOlderDriverTakesPyTorchsCUDA12Torch(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -130,3 +149,29 @@ func TestARequirementsFileIsReadForPackagesAndNothingElse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -592,6 +592,18 @@ var comfyuiReleases = map[string]struct {
|
||||
"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"
|
||||
|
||||
@@ -599,10 +611,15 @@ const ComfyUIRepo = "Comfy-Org/ComfyUI"
|
||||
// <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 — PyPI's, built
|
||||
// for CUDA 13, or PyTorch's CUDA 12.9 build for a driver CUDA 13 does not
|
||||
// run on.
|
||||
// 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
|
||||
@@ -647,8 +664,11 @@ print("comfyui version", "` + version + `", "on torch", torch.__version__)`},
|
||||
}
|
||||
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.0}, "ComfyUI " + version + " on CUDA 12.9'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)
|
||||
}
|
||||
@@ -243,6 +243,8 @@ 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",
|
||||
// Read by server init, node add and backups, not by the server.
|
||||
"backup_recipients", "backup_copy_to",
|
||||
}
|
||||
|
||||
+67
-22
@@ -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,34 +47,56 @@ 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
|
||||
@@ -107,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
|
||||
},
|
||||
}
|
||||
@@ -116,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>",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -465,6 +465,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)
|
||||
@@ -1281,3 +1289,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(¬e, " %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
|
||||
}})
|
||||
}
|
||||
@@ -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."
|
||||
})
|
||||
}}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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 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{}},
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+83
-15
@@ -108,6 +108,11 @@ type Params struct {
|
||||
}
|
||||
|
||||
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 != ""
|
||||
@@ -130,8 +135,44 @@ func (p Params) value(name string) (any, bool) {
|
||||
case "image":
|
||||
return p.Image, p.Image != ""
|
||||
}
|
||||
v, ok := p.Extra[name]
|
||||
return v, ok
|
||||
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.
|
||||
@@ -398,9 +439,17 @@ func (c *Client) fetch(ctx context.Context, base, apiKey string, h historyEntry)
|
||||
return nil, err
|
||||
}
|
||||
img.Name = f.Filename
|
||||
if t := typeByName(f.Filename); t != "" && (img.ContentType == "" ||
|
||||
strings.HasPrefix(img.ContentType, "application/octet-stream") || strings.HasPrefix(img.ContentType, "text/plain")) {
|
||||
img.ContentType = t
|
||||
// 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)
|
||||
}
|
||||
@@ -497,14 +546,12 @@ func (c *Client) get(ctx context.Context, u, apiKey string) (Image, error) {
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
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
|
||||
@@ -604,12 +651,33 @@ func typeByName(name string) string {
|
||||
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 picture in ComfyUI's input directory, where a LoadImage node
|
||||
// reads it, and returns the name it was given there.
|
||||
// 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
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -3,6 +3,7 @@ package comfy
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"crypto/subtle"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -99,6 +100,9 @@ func RunGate(ctx context.Context, cfg GateConfig) error {
|
||||
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
|
||||
@@ -111,6 +115,11 @@ func RunGate(ctx context.Context, cfg GateConfig) error {
|
||||
if err := LinkWeights(cfg.Weights, 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)
|
||||
}
|
||||
}
|
||||
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
|
||||
@@ -380,3 +389,71 @@ func relay(r io.Reader, w io.Writer) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -250,3 +250,14 @@ func TestComfyUIKeepsWritingWhenWhatTheGateWritesToIsGone(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+170
-3
@@ -2,13 +2,18 @@ package comfy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -23,6 +28,31 @@ 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.
|
||||
@@ -40,12 +70,17 @@ func installNodes(ctx context.Context, state, python string, nodes []Node, uv st
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 20*time.Minute)
|
||||
defer cancel()
|
||||
var reqs []string
|
||||
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]))
|
||||
@@ -61,13 +96,25 @@ func installNodes(ctx context.Context, state, python string, nodes []Node, uv st
|
||||
}
|
||||
}
|
||||
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 {
|
||||
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")
|
||||
@@ -83,7 +130,7 @@ func installNodes(ctx context.Context, state, python string, nodes []Node, uv st
|
||||
return "", fmt.Errorf("reading ComfyUI's packages: %w", err)
|
||||
}
|
||||
constraints := filepath.Join(state, "constraints.txt")
|
||||
if err := os.WriteFile(constraints, withoutNamed(freeze, reqs), 0o644); err != nil { //nolint:gosec // not secret
|
||||
if err := os.WriteFile(constraints, withoutNamed(freeze, named), 0o644); err != nil { //nolint:gosec // not secret
|
||||
return "", err
|
||||
}
|
||||
_ = os.RemoveAll(site)
|
||||
@@ -165,3 +212,123 @@ func packageName(spec string) string {
|
||||
}
|
||||
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,132 @@
|
||||
"""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())
|
||||
|
||||
|
||||
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."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"mesh": ("MESH",), "filename_prefix": ("STRING", {"default": "mesh"})}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "save"
|
||||
CATEGORY = "llmgw"
|
||||
|
||||
def save(self, mesh, filename_prefix="mesh"):
|
||||
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]):
|
||||
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}}
|
||||
|
||||
|
||||
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)"}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]}}
|
||||
}
|
||||
@@ -158,6 +158,9 @@ 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"`
|
||||
|
||||
// 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
|
||||
@@ -795,6 +798,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,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
@@ -1229,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})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,6 +65,7 @@ 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.",
|
||||
@@ -129,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",
|
||||
|
||||
@@ -279,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.
|
||||
@@ -287,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.
|
||||
//
|
||||
@@ -518,12 +526,53 @@ func (m Model) MakesMeshes() bool {
|
||||
// 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.
|
||||
@@ -549,12 +598,27 @@ type ComfyUI struct {
|
||||
// must.
|
||||
Checkpoint string `json:"checkpoint,omitempty" koanf:"checkpoint"`
|
||||
// Output is what the workflow makes: pictures (the default), asked for
|
||||
// at /v1/images/generations, or meshes, asked for as a job at
|
||||
// /v1/3d/generations from a picture.
|
||||
// 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.
|
||||
@@ -570,12 +634,24 @@ type CustomNode struct {
|
||||
// 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"
|
||||
|
||||
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*)*$`)
|
||||
@@ -590,6 +666,12 @@ func (n CustomNode) validate(model string) error {
|
||||
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.
|
||||
@@ -659,11 +741,17 @@ func (m Model) validateComfyUI() error {
|
||||
}
|
||||
}
|
||||
}
|
||||
if out := m.comfyOutput(); out != OutputPictures && out != OutputMeshes {
|
||||
return fmt.Errorf("model %q: comfyui.output %q is not pictures or meshes", m.Name, out)
|
||||
if err := validOutput(m.Name, m.comfyOutput()); err != nil {
|
||||
return err
|
||||
}
|
||||
if m.comfyOutput() == OutputMeshes && w == "" {
|
||||
return fmt.Errorf("model %q: a model that makes meshes names its workflow: no built-in makes them", m.Name)
|
||||
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",
|
||||
@@ -763,6 +851,9 @@ func New(models []Model) (*Catalog, error) {
|
||||
}
|
||||
canaryOf[target.Name] = name
|
||||
}
|
||||
if _, err := c.environments(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -897,6 +988,11 @@ func (m Model) validate() error {
|
||||
if m.Source == "" {
|
||||
return fmt.Errorf("model %q: source is required", m.Name)
|
||||
}
|
||||
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
|
||||
@@ -1011,10 +1107,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",
|
||||
@@ -1125,10 +1229,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,
|
||||
@@ -1177,19 +1287,23 @@ func (m Model) WeightSources() []string {
|
||||
|
||||
// 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 p.Engine == EngineComfyUI && m.ComfyUI != nil && len(m.ComfyUI.Nodes) > 0 {
|
||||
nodes, _ := json.Marshal(m.ComfyUI.Nodes)
|
||||
env = maps.Clone(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.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,
|
||||
Engine: p.Engine,
|
||||
@@ -1221,13 +1335,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
|
||||
}
|
||||
|
||||
@@ -1409,11 +1533,13 @@ func (e *External) validate(model string, profiles int) error {
|
||||
if err := checkWorkflow(model, e.Workflow); err != nil {
|
||||
return err
|
||||
}
|
||||
if e.Output != "" && e.Output != OutputPictures && e.Output != OutputMeshes {
|
||||
return fmt.Errorf("model %q: external output %q is not pictures or meshes", model, e.Output)
|
||||
if e.Output != "" {
|
||||
if err := validOutput(model, e.Output); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if e.Output == OutputMeshes && e.Workflow == "" {
|
||||
return fmt.Errorf("model %q: a model that makes meshes names its workflow: no built-in makes them", model)
|
||||
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)
|
||||
|
||||
@@ -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 == "" {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/comfy"
|
||||
)
|
||||
|
||||
func TestEveryPresetIsACatalogEntryThatLoads(t *testing.T) {
|
||||
var models []Model
|
||||
for _, p := range Presets() {
|
||||
models = append(models, p.Model)
|
||||
}
|
||||
cat, err := New(models)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range Presets() {
|
||||
m, ok := cat.Resolve(p.Model.Name)
|
||||
if !ok {
|
||||
t.Fatalf("%s is not in the catalog", p.Model.Name)
|
||||
}
|
||||
if p.Makes == OutputMeshes && !m.MakesMeshes() || p.Makes == OutputPictures && !m.MakesImages() {
|
||||
t.Errorf("%s does not make %s", m.Name, p.Makes)
|
||||
}
|
||||
// A shipped entry has its workflow inside llmgw, not in a file a
|
||||
// site would have to write.
|
||||
w, ckpt := m.ComfyWorkflow()
|
||||
if _, err := comfy.Load(w); err != nil {
|
||||
t.Errorf("%s: %v", m.Name, err)
|
||||
}
|
||||
if ckpt == "" {
|
||||
t.Errorf("%s has no checkpoint", m.Name)
|
||||
}
|
||||
if m.License == nil || m.License.ID == "" || m.License.Commercial == nil {
|
||||
t.Errorf("%s does not say its licence for a machine to match", m.Name)
|
||||
}
|
||||
for _, pr := range m.Profiles {
|
||||
if pr.GPUClass != "" || pr.GPU == nil {
|
||||
t.Errorf("%s/%s names a class: a preset fits any site's cards", m.Name, pr.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPresetIsFoundByName(t *testing.T) {
|
||||
if p, ok := PresetNamed("triposr"); !ok || p.Model.ComfyUI.Workflow != "triposr" {
|
||||
t.Errorf("triposr: %+v %v", p, ok)
|
||||
}
|
||||
if _, ok := PresetNamed("sdxl"); ok {
|
||||
t.Error("a preset that is not there was found")
|
||||
}
|
||||
// Each call is a fresh copy: a caller changing one changes nobody's.
|
||||
a, _ := PresetNamed("triposr")
|
||||
a.Model.ComfyUI.Nodes[0].Requirements[0] = "x"
|
||||
if b, _ := PresetNamed("triposr"); b.Model.ComfyUI.Nodes[0].Requirements[0] == "x" {
|
||||
t.Error("a preset was changed by a caller")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// What a ComfyUI workflow takes and what it makes, said in the catalog so a
|
||||
// model is data rather than code: a request is checked against its inputs
|
||||
// before a job is written, a client can build a form from /v1/models, and
|
||||
// the answer is picked from the files the workflow saved by its output kind.
|
||||
|
||||
// The kinds of thing a ComfyUI model makes, beside pictures and meshes.
|
||||
const (
|
||||
OutputVideo = "video"
|
||||
OutputAudio = "audio"
|
||||
// OutputFiles is whatever the workflow saves, of any type.
|
||||
OutputFiles = "files"
|
||||
)
|
||||
|
||||
// Outputs are every output kind a ComfyUI model may name.
|
||||
var Outputs = []string{OutputPictures, OutputMeshes, OutputVideo, OutputAudio, OutputFiles}
|
||||
|
||||
// The types of a workflow's input.
|
||||
const (
|
||||
InputText = "text"
|
||||
InputInt = "int"
|
||||
InputFloat = "float"
|
||||
InputBool = "bool"
|
||||
InputChoice = "choice"
|
||||
InputImage = "image"
|
||||
InputMask = "mask"
|
||||
InputAudio = "audio"
|
||||
InputVideo = "video"
|
||||
InputFile = "file"
|
||||
)
|
||||
|
||||
// InputTypes are every type an input may have.
|
||||
var InputTypes = []string{InputText, InputInt, InputFloat, InputBool, InputChoice,
|
||||
InputImage, InputMask, InputAudio, InputVideo, InputFile}
|
||||
|
||||
// Input is one thing a workflow takes from a request: the placeholder
|
||||
// {{name}} in its graph, filled with the request's inputs.name.
|
||||
type Input struct {
|
||||
// Name is the placeholder's, and the request's key.
|
||||
Name string `json:"name" koanf:"name"`
|
||||
// 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.
|
||||
Type string `json:"type" koanf:"type"`
|
||||
// Required inputs must be in every request; the others take Default,
|
||||
// or the workflow's own {{name|default}}.
|
||||
Required bool `json:"required,omitempty" koanf:"required"`
|
||||
Default any `json:"default,omitempty" koanf:"default"`
|
||||
// Min and Max bound a number; for text, Max bounds its length.
|
||||
Min *float64 `json:"min,omitempty" koanf:"min"`
|
||||
Max *float64 `json:"max,omitempty" koanf:"max"`
|
||||
// Choices are what a choice may be.
|
||||
Choices []string `json:"choices,omitempty" koanf:"choices"`
|
||||
// MaxMB bounds a file input; zero is the type's own bound.
|
||||
MaxMB int `json:"max_mb,omitempty" koanf:"max_mb"`
|
||||
// Doc says what the input is for, to whoever fills it in.
|
||||
Doc string `json:"doc,omitempty" koanf:"doc"`
|
||||
}
|
||||
|
||||
// IsFile reports whether the input is a file put in ComfyUI's input directory.
|
||||
func (in Input) IsFile() bool {
|
||||
switch in.Type {
|
||||
case InputImage, InputMask, InputAudio, InputVideo, InputFile:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MaxBytes is the most a file input may be.
|
||||
func (in Input) MaxBytes() int64 {
|
||||
if in.MaxMB > 0 {
|
||||
return int64(in.MaxMB) << 20
|
||||
}
|
||||
switch in.Type {
|
||||
case InputImage, InputMask:
|
||||
return 16 << 20
|
||||
case InputVideo:
|
||||
return 256 << 20
|
||||
}
|
||||
return 64 << 20
|
||||
}
|
||||
|
||||
var inputName = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`)
|
||||
|
||||
func (in Input) validate(model string) error {
|
||||
if !inputName.MatchString(in.Name) {
|
||||
return fmt.Errorf("model %q: input %q is not a name of lowercase letters, digits and '_'", model, in.Name)
|
||||
}
|
||||
if !slices.Contains(InputTypes, in.Type) {
|
||||
return fmt.Errorf("model %q: input %q has type %q, not one of %s", model, in.Name, in.Type, strings.Join(InputTypes, ", "))
|
||||
}
|
||||
if in.Type == InputChoice && len(in.Choices) == 0 {
|
||||
return fmt.Errorf("model %q: input %q is a choice with no choices", model, in.Name)
|
||||
}
|
||||
if in.Type != InputChoice && len(in.Choices) > 0 {
|
||||
return fmt.Errorf("model %q: input %q has choices but is not a choice", model, in.Name)
|
||||
}
|
||||
if in.Min != nil && in.Max != nil && *in.Min > *in.Max {
|
||||
return fmt.Errorf("model %q: input %q has min above max", model, in.Name)
|
||||
}
|
||||
if in.Required && in.Default != nil {
|
||||
return fmt.Errorf("model %q: input %q is required and has a default: it is one or the other", model, in.Name)
|
||||
}
|
||||
if in.IsFile() && in.Default != nil {
|
||||
return fmt.Errorf("model %q: input %q is a file and cannot have a default", model, in.Name)
|
||||
}
|
||||
if in.MaxMB < 0 {
|
||||
return fmt.Errorf("model %q: input %q has a negative max_mb", model, in.Name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ComfyOutput is what a ComfyUI model makes: pictures unless it says.
|
||||
func (m Model) ComfyOutput() string { return m.comfyOutput() }
|
||||
|
||||
// ComfyInputs are what a ComfyUI model's workflow takes from a request.
|
||||
func (m Model) ComfyInputs() []Input {
|
||||
if m.ComfyUI == nil {
|
||||
return nil
|
||||
}
|
||||
return m.ComfyUI.Inputs
|
||||
}
|
||||
|
||||
// RunsWorkflows reports whether a model answers /v1/workflows/runs: any the
|
||||
// cluster's ComfyUI or a ComfyUI outside it serves.
|
||||
func (m Model) RunsWorkflows() bool { return m.ServesComfyUI() }
|
||||
|
||||
func validOutput(model, out string) error {
|
||||
if !slices.Contains(Outputs, out) {
|
||||
return fmt.Errorf("model %q: comfyui.output %q is not one of %s", model, out, strings.Join(Outputs, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateInputs(model string, inputs []Input) error {
|
||||
seen := map[string]bool{}
|
||||
for _, in := range inputs {
|
||||
if err := in.validate(model); err != nil {
|
||||
return err
|
||||
}
|
||||
if seen[in.Name] {
|
||||
return fmt.Errorf("model %q: input %q is declared twice", model, in.Name)
|
||||
}
|
||||
seen[in.Name] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAWorkflowsInputsAreCheckedWhenTheCatalogIsBuilt(t *testing.T) {
|
||||
one := 1.0
|
||||
zero := 0.0
|
||||
run := func(out string, inputs ...Input) Model {
|
||||
return comfyModel("/w/ace.safetensors", &ComfyUI{Workflow: "w.json", Output: out, Inputs: inputs})
|
||||
}
|
||||
cat, err := New([]Model{run(OutputAudio,
|
||||
Input{Name: "tags", Type: InputText, Required: true},
|
||||
Input{Name: "strength", Type: InputFloat, Min: &zero, Max: &one, Default: 0.5},
|
||||
Input{Name: "style", Type: InputChoice, Choices: []string{"pop"}},
|
||||
Input{Name: "clip", Type: InputVideo, MaxMB: 512})})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, _ := cat.Resolve("flux")
|
||||
if !m.RunsWorkflows() || m.MakesImages() || m.MakesMeshes() || m.ComfyOutput() != OutputAudio || len(m.ComfyInputs()) != 4 {
|
||||
t.Errorf("an audio model: runs %v pictures %v meshes %v output %q", m.RunsWorkflows(), m.MakesImages(), m.MakesMeshes(), m.ComfyOutput())
|
||||
}
|
||||
if m.ComfyInputs()[3].MaxBytes() != 512<<20 || (Input{Type: InputImage}).MaxBytes() != 16<<20 {
|
||||
t.Error("a file input's bound")
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
m Model
|
||||
want string
|
||||
}{
|
||||
"an unknown kind": {run("hologram"), "not one of pictures"},
|
||||
"video, no workflow": {comfyModel("/w/x", &ComfyUI{Output: OutputVideo}), "makes video names its workflow"},
|
||||
"a capital name": {run(OutputAudio, Input{Name: "Tags", Type: InputText}), "lowercase"},
|
||||
"an unknown type": {run(OutputAudio, Input{Name: "x", Type: "colour"}), "not one of text"},
|
||||
"a choice of nothing": {run(OutputAudio, Input{Name: "x", Type: InputChoice}), "no choices"},
|
||||
"min above max": {run(OutputAudio, Input{Name: "x", Type: InputInt, Min: &one, Max: &zero}), "min above max"},
|
||||
"required and default": {run(OutputAudio, Input{Name: "x", Type: InputInt, Required: true, Default: 3}), "one or the other"},
|
||||
"a file's default": {run(OutputAudio, Input{Name: "x", Type: InputImage, Default: "a.png"}), "cannot have a default"},
|
||||
"twice": {run(OutputAudio, Input{Name: "x", Type: InputText}, Input{Name: "x", Type: InputInt}), "declared twice"},
|
||||
} {
|
||||
if _, err := New([]Model{c.m}); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -372,7 +372,7 @@ func (c *Controller) endpoints() []endpoint.Info {
|
||||
Priority: prof.Priority,
|
||||
}
|
||||
if prof.Engine == catalog.EngineComfyUI {
|
||||
workflow, ckpt := m.ComfyWorkflow()
|
||||
workflow, ckpt := m.ComfyWorkflowFor(prof)
|
||||
w, err := c.workflow(workflow)
|
||||
if err != nil {
|
||||
c.log.Warn("a ComfyUI model's workflow could not be read; its replica is not offered",
|
||||
|
||||
@@ -119,6 +119,14 @@ func (r *Reconciler) Adopt(nodeID string, shards []AdoptedShard) (unclaimed []Ad
|
||||
if _, asked := r.wanted[m.Name]; !asked && m.Scaling.OnDemand() {
|
||||
r.wanted[m.Name] = r.now()
|
||||
}
|
||||
// And counted as used now, for the same reason. Left unknown it was
|
||||
// never idle enough to evict: a 3D job waited out the whole fifteen
|
||||
// minutes of a picture model nobody had asked for since the restart.
|
||||
// A replica in use is reported by the router within seconds, and
|
||||
// moves this forward.
|
||||
if m.Scaling.OnDemand() {
|
||||
rep.LastUsedAt = r.now()
|
||||
}
|
||||
}
|
||||
return unclaimed
|
||||
}
|
||||
|
||||
@@ -64,15 +64,37 @@ func (r *Reconciler) makeRoomFor(ctx context.Context, m catalog.Model) bool {
|
||||
attribute.Int("llmgw.candidates", len(candidates)))
|
||||
defer span.End()
|
||||
r.stopReplica(ctx, best.ID, "evicted, idle, to make room for "+m.Name)
|
||||
r.forgetWantIfGone(best.Model, best.ID)
|
||||
return true
|
||||
}
|
||||
|
||||
// forgetWantIfGone takes an evicted model off the wanted list when that was
|
||||
// its last replica. Still wanted, it was placed again on the next pass —
|
||||
// ahead of the model it made room for whenever it came first in the catalog —
|
||||
// and the card went back to the model nobody was asking for. Asked again, it
|
||||
// is wanted again.
|
||||
func (r *Reconciler) forgetWantIfGone(model, stopped string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
for _, rep := range r.replicas {
|
||||
if rep.Model == model && rep.ID != stopped && rep.State.Counts() {
|
||||
return
|
||||
}
|
||||
}
|
||||
delete(r.wanted, model)
|
||||
}
|
||||
|
||||
// evictable lists replicas that could be stopped for a model that needs room,
|
||||
// longest idle first.
|
||||
func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
|
||||
cat := r.cat.Load()
|
||||
now := r.now()
|
||||
|
||||
wantRank := -1
|
||||
if w, ok := cat.Resolve(wantedBy); ok {
|
||||
wantRank = catalog.ClassRank(w.EffectiveClass())
|
||||
}
|
||||
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
@@ -85,6 +107,12 @@ func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
|
||||
if !ok || !other.Scaling.OnDemand() {
|
||||
continue
|
||||
}
|
||||
// Not for a model of a lower class: the one put first keeps the
|
||||
// card through its idle timeout, rather than losing it two minutes
|
||||
// into a lull to whatever was asked for next.
|
||||
if catalog.ClassRank(other.EffectiveClass()) > wantRank {
|
||||
continue
|
||||
}
|
||||
// Unknown is not idle. A replica the router has said nothing about —
|
||||
// after a restart, that is all of them — must not be evicted on the
|
||||
// strength of a fact nobody has reported.
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package reconciler
|
||||
|
||||
import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/agent/discovery"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
)
|
||||
|
||||
// gpuKinds are the kinds of card the cluster's nodes have, for a profile that
|
||||
// names its GPU by what it needs rather than by class to stand for.
|
||||
func (r *Reconciler) gpuKinds() []catalog.GPUKind {
|
||||
var gpus []discovery.GPU
|
||||
for _, n := range r.inv.Nodes() {
|
||||
gpus = append(gpus, n.GPUs...)
|
||||
}
|
||||
return catalog.GPUKinds(gpus)
|
||||
}
|
||||
@@ -134,21 +134,122 @@ func (r *Reconciler) Unservable(model string) (string, bool) {
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
cat := r.cat.Load()
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
if p, ok := r.parked[m.Name]; ok {
|
||||
r.mu.Unlock()
|
||||
return fmt.Sprintf("%s failed to start %d times in a row and is not being retried: %s",
|
||||
m.Name, p.Failures, p.Reason), true
|
||||
}
|
||||
var asleepLater []victim
|
||||
for _, rep := range r.replicas {
|
||||
if rep.Model == m.Name && rep.State.Counts() {
|
||||
if !rep.State.Counts() {
|
||||
continue
|
||||
}
|
||||
if rep.Model == m.Name {
|
||||
r.mu.Unlock()
|
||||
return "", false // something is on its way
|
||||
}
|
||||
if other, ok := cat.Resolve(rep.Model); ok && other.Scaling.OnDemand() && len(nodesOf(rep)) == 1 {
|
||||
asleepLater = append(asleepLater, victim{ID: rep.ID, Model: rep.Model})
|
||||
}
|
||||
}
|
||||
if res, ok := r.lastExplain[m.Name]; ok && res.Placement == nil {
|
||||
return m.Name + " cannot be placed on any node: " + firstLine(res.Explain()), true
|
||||
res, ok := r.lastExplain[m.Name]
|
||||
r.mu.Unlock()
|
||||
if !ok || res.Placement != nil {
|
||||
return "", false
|
||||
}
|
||||
return "", false
|
||||
// The cards are held by models that let go of them when idle, which
|
||||
// makeRoomFor hastens once they have been up a while. That is a wait, not
|
||||
// a never: a 3D model asked for straight after a picture, on a cluster
|
||||
// with one card, waits for the picture model to step aside.
|
||||
if m.Scaling.OnDemand() && len(asleepLater) > 0 && r.fitsWithout(m, nil, asleepLater) {
|
||||
return "", false
|
||||
}
|
||||
return m.Name + " cannot be placed on any node: " + firstLine(res.Explain()), true
|
||||
}
|
||||
|
||||
// WaitFor says which model holds the cards an on-demand model is waiting
|
||||
// for, and about how long until it serves: until the holder lets go — evicted
|
||||
// once idle and old enough, or stopped at its own idle timeout — and then its
|
||||
// own start, as long as its last one took. ok is false when it is not waiting
|
||||
// on another model; eta is zero when when the holder lets go is not known.
|
||||
func (r *Reconciler) WaitFor(model string) (heldBy string, eta time.Duration, ok bool) {
|
||||
cat := r.cat.Load()
|
||||
m, found := cat.Resolve(model)
|
||||
if !found || !m.Scaling.OnDemand() {
|
||||
return "", 0, false
|
||||
}
|
||||
type holder struct {
|
||||
v victim
|
||||
release time.Time
|
||||
}
|
||||
now := r.now()
|
||||
var holders []holder
|
||||
r.mu.Lock()
|
||||
for _, rep := range r.replicas {
|
||||
if !rep.State.Counts() || len(nodesOf(rep)) != 1 {
|
||||
continue
|
||||
}
|
||||
if rep.Model == m.Name {
|
||||
r.mu.Unlock()
|
||||
return "", 0, false // its own replica is on its way
|
||||
}
|
||||
other, found := cat.Resolve(rep.Model)
|
||||
if !found || !other.Scaling.OnDemand() {
|
||||
continue
|
||||
}
|
||||
h := holder{v: victim{ID: rep.ID, Model: rep.Model}}
|
||||
if !rep.LastUsedAt.IsZero() {
|
||||
h.release = rep.LastUsedAt.Add(r.idleTimeout(other))
|
||||
// Evicted sooner, when it is no higher a class than the model
|
||||
// waiting; one put first is waited for to its idle timeout.
|
||||
if catalog.ClassRank(other.EffectiveClass()) <= catalog.ClassRank(m.EffectiveClass()) {
|
||||
evict := rep.LastUsedAt.Add(r.cfg.EvictionGrace)
|
||||
if !rep.ReadyAt.IsZero() && rep.ReadyAt.Add(r.cfg.MinLifetime).After(evict) {
|
||||
evict = rep.ReadyAt.Add(r.cfg.MinLifetime)
|
||||
}
|
||||
if evict.Before(h.release) {
|
||||
h.release = evict
|
||||
}
|
||||
}
|
||||
}
|
||||
holders = append(holders, h)
|
||||
}
|
||||
cold := r.coldStart[m.Name]
|
||||
r.mu.Unlock()
|
||||
|
||||
// The one whose going alone makes room, letting go soonest; failing
|
||||
// that, all of them, and the last to let go.
|
||||
var best *holder
|
||||
for i := range holders {
|
||||
h := &holders[i]
|
||||
if !r.fitsWithout(m, nil, []victim{h.v}) {
|
||||
continue
|
||||
}
|
||||
if best == nil || (!h.release.IsZero() && (best.release.IsZero() || h.release.Before(best.release))) {
|
||||
best = h
|
||||
}
|
||||
}
|
||||
if best == nil {
|
||||
all := make([]victim, len(holders))
|
||||
for i, h := range holders {
|
||||
all[i] = h.v
|
||||
}
|
||||
if len(all) == 0 || !r.fitsWithout(m, nil, all) {
|
||||
return "", 0, false
|
||||
}
|
||||
for i := range holders {
|
||||
h := &holders[i]
|
||||
if best == nil || h.release.IsZero() || (!best.release.IsZero() && h.release.After(best.release)) {
|
||||
best = h
|
||||
}
|
||||
}
|
||||
}
|
||||
if best.release.IsZero() {
|
||||
return best.v.Model, 0, true
|
||||
}
|
||||
return best.v.Model, max(best.release.Sub(now), 0) + cold, true
|
||||
}
|
||||
|
||||
func firstLine(s string) string {
|
||||
|
||||
@@ -45,7 +45,7 @@ func (r *Reconciler) measured(model, profile string) (Measured, bool) {
|
||||
// Placement sorts by priority itself, so the order found here is written back
|
||||
// as priorities, descending, for it to keep.
|
||||
func (r *Reconciler) orderedProfiles(m catalog.Model) []placement.Profile {
|
||||
ps := m.PlacementProfiles()
|
||||
ps := m.PlacementProfilesFor(r.gpuKinds())
|
||||
eff := make([]float64, len(ps))
|
||||
for i, p := range ps {
|
||||
meas, ok := r.measured(m.Name, p.Name)
|
||||
|
||||
@@ -264,6 +264,9 @@ type Reconciler struct {
|
||||
// because it is state about this moment and not about what the model is.
|
||||
// A model nobody has wanted has no entry and is simply not running.
|
||||
wanted map[string]time.Time
|
||||
// coldStart is how long each model's last replica took from placed to
|
||||
// ready, for telling a request waiting on it about how long it waits.
|
||||
coldStart map[string]time.Duration
|
||||
// restartQueue holds, per model, the replicas an operator asked to be
|
||||
// restarted and not yet begun. One is replaced at a time: replacing them
|
||||
// all at once would need the memory of every replica twice over.
|
||||
@@ -330,6 +333,7 @@ func New(cfg Config, cat *catalog.Live, inv *inventory.Store, disp Dispatcher, l
|
||||
attempts: map[string]int{},
|
||||
reservedPorts: map[int]struct{}{},
|
||||
wanted: map[string]time.Time{},
|
||||
coldStart: map[string]time.Duration{},
|
||||
restartQueue: map[string][]string{},
|
||||
restartInPlace: map[string]bool{},
|
||||
restartPace: map[string]RestartPace{},
|
||||
@@ -478,6 +482,13 @@ func (r *Reconciler) stopReplicasOfRemovedModels(ctx context.Context) {
|
||||
gone = append(gone, rep)
|
||||
}
|
||||
}
|
||||
// And whether it was wanted: a model removed and added again, to change
|
||||
// its entry, came back loading as if just asked for.
|
||||
for model := range r.wanted {
|
||||
if _, ok := cat.Resolve(model); !ok {
|
||||
delete(r.wanted, model)
|
||||
}
|
||||
}
|
||||
r.mu.Unlock()
|
||||
|
||||
for _, rep := range gone {
|
||||
@@ -1371,6 +1382,9 @@ func (r *Reconciler) markShard(replicaID, shardID string, st supervisor.State, e
|
||||
// counting from scratch rather than from an old streak.
|
||||
r.attempts[rep.Model] = 0
|
||||
delete(r.nextAttempt, rep.Model)
|
||||
if !rep.CreatedAt.IsZero() {
|
||||
r.coldStart[rep.Model] = r.now().Sub(rep.CreatedAt)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -680,6 +680,25 @@ func TestAdoptRebuildsReplica(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnAdoptedOnDemandReplicaNobodyUsesCanBeEvicted(t *testing.T) {
|
||||
// After a restart the picture model, adopted and asked for by nobody,
|
||||
// was never idle enough to evict: a 3D job waited out its whole idle
|
||||
// timeout, told nothing about how long.
|
||||
h := newHarness(t, testModel(1), gpuNode("1", 4))
|
||||
h.r.cat.Store(twoOnDemandModels(t))
|
||||
h.r.Adopt("1", []AdoptedShard{adopted("llama-99", "llama-99-r0", "1", 0, []int{0, 1, 2, 3})})
|
||||
|
||||
h.r.Want("second")
|
||||
h.r.Reconcile(t.Context())
|
||||
if _, eta, ok := h.r.WaitFor("second"); !ok || eta <= 0 {
|
||||
t.Errorf("WaitFor: eta %s, ok %v", eta, ok)
|
||||
}
|
||||
h.advance(DefaultConfig().MinLifetime + time.Minute)
|
||||
if got := h.r.evictable("second"); len(got) != 1 || got[0].ID != "llama-99" {
|
||||
t.Errorf("evictable %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdoptPreventsDuplicatePlacement(t *testing.T) {
|
||||
// The whole point: after adopting, a reconciliation pass must see the
|
||||
// desired count already met.
|
||||
@@ -1318,6 +1337,72 @@ func TestAModelRemovedWhileRunningIsStopped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModelPutFirstIsNotEvictedForALowerClassOne(t *testing.T) {
|
||||
// The VLM is put first: a picture or 3D model asked for in a lull must
|
||||
// wait for its idle timeout, not take the card two minutes in.
|
||||
h := newHarness(t, testModel(1), gpuNode("1", 4))
|
||||
cat := twoOnDemandModels(t)
|
||||
ms := cat.Models()
|
||||
for i := range ms {
|
||||
if ms[i].Name == "llama" {
|
||||
ms[i].Class = catalog.ClassStandard
|
||||
} else {
|
||||
ms[i].Class = catalog.ClassOpportunistic
|
||||
}
|
||||
}
|
||||
built, err := catalog.New(ms)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.r.cat.Store(built)
|
||||
h.r.Want("llama")
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
reps := h.r.Replicas()
|
||||
h.r.RecordUsage(map[string]time.Time{reps[0].ID: h.clock.now()})
|
||||
h.advance(DefaultConfig().MinLifetime + DefaultConfig().EvictionGrace + time.Minute)
|
||||
|
||||
if got := h.r.evictable("second"); len(got) != 0 {
|
||||
t.Errorf("a standard model was offered to an opportunistic one: %+v", got)
|
||||
}
|
||||
h.r.Want("second")
|
||||
h.r.Reconcile(t.Context())
|
||||
// It waits for the idle timeout (an hour here), and is told so.
|
||||
_, eta, ok := h.r.WaitFor("second")
|
||||
want := time.Hour - (DefaultConfig().MinLifetime + DefaultConfig().EvictionGrace + time.Minute)
|
||||
if !ok || eta != want {
|
||||
t.Errorf("WaitFor: %s %v, want %s", eta, ok, want)
|
||||
}
|
||||
// The other way, it is offered.
|
||||
if got := h.r.evictable("llama"); len(got) != 0 {
|
||||
t.Errorf("nothing of second runs, yet %+v was offered", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOnDemandModelRemovedAndAddedAgainIsNotLoadedUnasked(t *testing.T) {
|
||||
// The VLM, removed and added again to give it a licence, came back
|
||||
// loading on its own: still on the wanted list from before its removal.
|
||||
h := newHarness(t, testModel(1), gpuNode("1", 4))
|
||||
h.r.cat.Store(twoOnDemandModels(t))
|
||||
h.r.Want("llama")
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
|
||||
only := mustResolve(t, h, "second")
|
||||
without, err := catalog.New([]catalog.Model{only})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.r.cat.Store(without)
|
||||
for range 3 {
|
||||
h.r.Reconcile(t.Context())
|
||||
}
|
||||
h.r.cat.Store(twoOnDemandModels(t))
|
||||
if n := h.r.baseFor(mustResolve(t, h, "llama")); n != 0 {
|
||||
t.Errorf("added again, it wants %d replicas before anybody asked", n)
|
||||
}
|
||||
}
|
||||
|
||||
// --- on-demand loading and idle eviction ---
|
||||
|
||||
func onDemandModel(idle time.Duration) catalog.Model {
|
||||
@@ -1708,6 +1793,109 @@ func TestAFreshlyStartedReplicaIsNotEvicted(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEvictedModelDoesNotTakeTheCardBack(t *testing.T) {
|
||||
// flux evicted for triposr was placed again two seconds later: still
|
||||
// wanted, and first in the catalog, it won the card it had just given up.
|
||||
h := newHarness(t, testModel(1), gpuNode("1", 4))
|
||||
h.r.cat.Store(twoOnDemandModels(t))
|
||||
h.r.Want("llama")
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
first := h.r.Replicas()
|
||||
if len(first) != 1 {
|
||||
t.Fatalf("setup: %d replicas", len(first))
|
||||
}
|
||||
h.r.RecordUsage(map[string]time.Time{first[0].ID: h.clock.now()})
|
||||
h.advance(DefaultConfig().MinLifetime + time.Minute)
|
||||
|
||||
h.r.Want("second")
|
||||
for range 4 {
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
h.advance(10 * time.Second)
|
||||
}
|
||||
var models []string
|
||||
for _, rep := range h.r.Replicas() {
|
||||
if rep.State.Counts() {
|
||||
models = append(models, rep.Model)
|
||||
}
|
||||
}
|
||||
if !slices.Equal(models, []string{"second"}) {
|
||||
t.Errorf("running after the eviction: %v, want [second]", models)
|
||||
}
|
||||
|
||||
// Asked for again, it is wanted again.
|
||||
if !h.r.Want("llama") || h.r.baseFor(mustResolve(t, h, "llama")) != 1 {
|
||||
t.Error("an evicted model asked for again is not wanted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModelWaitingOnAnotherIsToldWhichAndForAboutHowLong(t *testing.T) {
|
||||
h := newHarness(t, testModel(1), gpuNode("1", 4))
|
||||
h.r.cat.Store(twoOnDemandModels(t))
|
||||
h.r.Want("llama")
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
reps := h.r.Replicas()
|
||||
if len(reps) != 1 {
|
||||
t.Fatalf("setup: %d replicas", len(reps))
|
||||
}
|
||||
h.r.RecordUsage(map[string]time.Time{reps[0].ID: h.clock.now()})
|
||||
h.advance(time.Minute)
|
||||
|
||||
h.r.Want("second")
|
||||
h.r.Reconcile(t.Context())
|
||||
// Evicted once it has been up MinLifetime, a minute of which has gone;
|
||||
// second has never started, so there is no start of its to add.
|
||||
by, eta, ok := h.r.WaitFor("second")
|
||||
if !ok || by != "llama" || eta != DefaultConfig().MinLifetime-time.Minute {
|
||||
t.Errorf("WaitFor = %q %s %v", by, eta, ok)
|
||||
}
|
||||
// The model holding the cards waits on nobody.
|
||||
if _, _, ok := h.r.WaitFor("llama"); ok {
|
||||
t.Error("a model with its own replica was said to be waiting")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModelWaitingForAnotherOnDemandOneToStepAsideIsNotUnservable(t *testing.T) {
|
||||
// A 3D model asked for straight after a picture, on a cluster with one
|
||||
// card: the picture model holds it, too freshly started to evict. That
|
||||
// is a wait — told "cannot be placed", a job failed at once.
|
||||
h := newHarness(t, testModel(1), gpuNode("1", 4))
|
||||
h.r.cat.Store(twoOnDemandModels(t))
|
||||
h.r.Want("llama")
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
reps := h.r.Replicas()
|
||||
if len(reps) != 1 {
|
||||
t.Fatalf("setup: %d replicas", len(reps))
|
||||
}
|
||||
h.r.RecordUsage(map[string]time.Time{reps[0].ID: h.clock.now()})
|
||||
|
||||
h.r.Want("second")
|
||||
h.r.Reconcile(t.Context())
|
||||
if res, ok := h.r.lastExplain["second"]; !ok || res.Placement != nil {
|
||||
t.Fatalf("setup: second was not left unplaced: %+v", res)
|
||||
}
|
||||
if why, no := h.r.Unservable("second"); no {
|
||||
t.Errorf("a model waiting for an on-demand one to go idle was called unservable: %q", why)
|
||||
}
|
||||
|
||||
// Held by a model that stays up, the card is not coming back.
|
||||
always := testModel(1)
|
||||
always.Name = "llama"
|
||||
always.Scaling = catalog.Scaling{Min: 1, Max: 1}
|
||||
second := mustResolve(t, h, "second")
|
||||
built, err := catalog.New([]catalog.Model{always, second})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h.r.cat.Store(built)
|
||||
if _, no := h.r.Unservable("second"); !no {
|
||||
t.Error("a model behind an always-on one was not called unservable")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStartedShardIsToldWhichProfileVersionItIs(t *testing.T) {
|
||||
// The fingerprint has to leave the control plane, or it cannot come back
|
||||
// after a restart — and a restart is exactly when the configuration is
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/clientapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/docgen"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/docgen/openapi"
|
||||
@@ -57,4 +58,15 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("docs/admin-api.openapi.json: %d routes\n", len(adminapi.Routes))
|
||||
|
||||
spec, err = openapi.GenerateDoc(root, openapi.Client, clientapi.Routes, clientapi.PathParams)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "docs", "client-api.openapi.json"), spec, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "error:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("docs/client-api.openapi.json: %d routes\n", len(clientapi.Routes))
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/clientapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
@@ -35,10 +36,68 @@ import (
|
||||
// from any other is described without comments.
|
||||
const Module = "gitea.mynodes.xyz/ilker/llmgw"
|
||||
|
||||
// Generate renders the document for routes, reading doc comments from the
|
||||
// Doc is what one document says of itself and its API: the two APIs llmgw
|
||||
// has, the admin listener's and the client's, are described by one
|
||||
// generator from their route tables.
|
||||
type Doc struct {
|
||||
Title, Description string
|
||||
// ServerURL and ServerDescription are where it is served by default.
|
||||
ServerURL, ServerDescription string
|
||||
// Scheme is the bearer token's name in the document, SchemeDescription
|
||||
// what it is.
|
||||
Scheme, SchemeDescription string
|
||||
// Error is a value of the type every refusal answers with.
|
||||
Error any
|
||||
// Prefix is taken off paths to name operations and tags.
|
||||
Prefix string
|
||||
// Home is the package whose types are named alone; others are named
|
||||
// with their package.
|
||||
Home string
|
||||
// Actor is whether changes take the admin API's X-Llmgw-Actor header.
|
||||
Actor bool
|
||||
}
|
||||
|
||||
// Admin is the admin API's document.
|
||||
var Admin = Doc{
|
||||
Title: "llmgw admin API", Description: infoDescription,
|
||||
ServerURL: "http://127.0.0.1:9090", ServerDescription: "The admin listener at its default address, admin_listen.",
|
||||
Scheme: "adminToken",
|
||||
SchemeDescription: "The admin token: the contents of /var/lib/llmgw/admin-token on the " +
|
||||
"control plane (admin_token_file), readable by root and the server. " +
|
||||
"Sent as Authorization: Bearer TOKEN.",
|
||||
Error: adminapi.ErrorView{}, Prefix: "/api/v1/", Home: reflect.TypeOf(adminapi.Route{}).PkgPath(), Actor: true,
|
||||
}
|
||||
|
||||
// Client is the client API's document: the API on :8080 clients use.
|
||||
var Client = Doc{
|
||||
Title: "llmgw client API", Description: clientDescription,
|
||||
ServerURL: "http://localhost:8080", ServerDescription: "A gateway at its default address, listen.",
|
||||
Scheme: "apiKey",
|
||||
SchemeDescription: "An API key, sk-llmgw-…, made with `llmgw keys create` or on the portal's profile page. " +
|
||||
"Sent as Authorization: Bearer KEY; /v1/messages also takes it as x-api-key.",
|
||||
Error: clientapi.Error{}, Prefix: "/v1/", Home: reflect.TypeOf(clientapi.Error{}).PkgPath(),
|
||||
}
|
||||
|
||||
const clientDescription = "OpenAI's API, as llmgw serves it, and what llmgw adds: pictures and 3D models from " +
|
||||
"ComfyUI, knowledge bases, memory notes, stored conversations.\n\n" +
|
||||
"The SDKs of OpenAI and Anthropic work against it unchanged: set the base URL to the gateway's /v1 and the " +
|
||||
"key to an llmgw key. A request carries more than is listed here: what the gateway does not read goes to " +
|
||||
"the engine as it came. Headers llmgw reads: x-llmgw-thread (continue a stored conversation), " +
|
||||
"x-llmgw-knowledge (answer from these collections), x-llmgw-tags (tags kept with the usage), " +
|
||||
"x-session-id (stay on one replica). Headers it sends back: x-request-id, x-llmgw-served-model, " +
|
||||
"x-llmgw-hardware, x-llmgw-seed, and Retry-After when waiting helps.\n\n" +
|
||||
"Generated from the route table in internal/clientapi; see docs/clients.md."
|
||||
|
||||
// Generate renders the admin API's document, reading doc comments from the
|
||||
// repository at root.
|
||||
func Generate(root string, routes []adminapi.Route, pathParams map[string]string) ([]byte, error) {
|
||||
g := &generator{root: root, docs: map[string]map[string]typeDoc{}, names: map[reflect.Type]string{}}
|
||||
return GenerateDoc(root, Admin, routes, pathParams)
|
||||
}
|
||||
|
||||
// GenerateDoc renders one API's document.
|
||||
func GenerateDoc(root string, d Doc, routes []adminapi.Route, pathParams map[string]string) ([]byte, error) {
|
||||
errorType := reflect.TypeOf(d.Error)
|
||||
g := &generator{root: root, docs: map[string]map[string]typeDoc{}, names: map[reflect.Type]string{}, d: d}
|
||||
|
||||
// Every named struct reachable from a body first, so a name two
|
||||
// packages share can be told apart before either is written: by the name
|
||||
@@ -68,7 +127,7 @@ func Generate(root string, routes []adminapi.Route, pathParams map[string]string
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tags[tagOf(r.Path)] = true
|
||||
tags[g.tagOf(r.Path)] = true
|
||||
item.set(strings.ToLower(r.Method), op)
|
||||
}
|
||||
slices.Sort(order)
|
||||
@@ -92,42 +151,42 @@ func Generate(root string, routes []adminapi.Route, pathParams map[string]string
|
||||
tagList = append(tagList, obj("name", t))
|
||||
}
|
||||
|
||||
components := obj(
|
||||
"securitySchemes", obj(d.Scheme, obj(
|
||||
"type", "http",
|
||||
"scheme", "bearer",
|
||||
"description", d.SchemeDescription,
|
||||
)),
|
||||
)
|
||||
if d.Actor {
|
||||
components.set("parameters", obj("Actor", obj(
|
||||
"name", "X-Llmgw-Actor",
|
||||
"in", "header",
|
||||
"required", false,
|
||||
"description", "Who the change is recorded as made by in the audit log: letters, digits "+
|
||||
"and ._-@, at most 64. unknown when absent.",
|
||||
"schema", obj("type", "string"),
|
||||
)))
|
||||
}
|
||||
components.set("responses", obj(
|
||||
"Error", obj("description", "Refused: the error says why.",
|
||||
"content", obj("application/json", obj("schema", errorSchema))),
|
||||
"Unauthorized", obj("description", "The token is missing or wrong.",
|
||||
"content", obj("application/json", obj("schema", errorSchema))),
|
||||
))
|
||||
components.set("schemas", schemas)
|
||||
doc := obj(
|
||||
"openapi", "3.1.0",
|
||||
"info", obj(
|
||||
"title", "llmgw admin API",
|
||||
"title", d.Title,
|
||||
"version", "v1",
|
||||
"description", infoDescription,
|
||||
"description", d.Description,
|
||||
),
|
||||
"servers", []any{obj("url", "http://127.0.0.1:9090",
|
||||
"description", "The admin listener at its default address, admin_listen.")},
|
||||
"security", []any{obj("adminToken", []any{})},
|
||||
"servers", []any{obj("url", d.ServerURL, "description", d.ServerDescription)},
|
||||
"security", []any{obj(d.Scheme, []any{})},
|
||||
"tags", tagList,
|
||||
"paths", pathsObj,
|
||||
"components", obj(
|
||||
"securitySchemes", obj("adminToken", obj(
|
||||
"type", "http",
|
||||
"scheme", "bearer",
|
||||
"description", "The admin token: the contents of /var/lib/llmgw/admin-token on the "+
|
||||
"control plane (admin_token_file), readable by root and the server. "+
|
||||
"Sent as Authorization: Bearer TOKEN.",
|
||||
)),
|
||||
"parameters", obj("Actor", obj(
|
||||
"name", "X-Llmgw-Actor",
|
||||
"in", "header",
|
||||
"required", false,
|
||||
"description", "Who the change is recorded as made by in the audit log: letters, digits "+
|
||||
"and ._-@, at most 64. unknown when absent.",
|
||||
"schema", obj("type", "string"),
|
||||
)),
|
||||
"responses", obj(
|
||||
"Error", obj("description", "Refused: the error says why.",
|
||||
"content", obj("application/json", obj("schema", errorSchema))),
|
||||
"Unauthorized", obj("description", "The admin token is missing or wrong.",
|
||||
"content", obj("application/json", obj("schema", errorSchema))),
|
||||
),
|
||||
"schemas", schemas,
|
||||
),
|
||||
"components", components,
|
||||
)
|
||||
b, err := marshal(doc)
|
||||
if err != nil {
|
||||
@@ -154,12 +213,12 @@ const infoDescription = "The control plane's management API: what `llmgw ctl` an
|
||||
// operation builds one route's operation object.
|
||||
func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (object, error) {
|
||||
var op object
|
||||
op.set("operationId", operationID(r.Method, r.Path))
|
||||
op.set("operationId", g.operationID(r.Method, r.Path))
|
||||
op.set("summary", r.Summary)
|
||||
if r.Description != "" {
|
||||
op.set("description", r.Description)
|
||||
}
|
||||
op.set("tags", []any{tagOf(r.Path)})
|
||||
op.set("tags", []any{g.tagOf(r.Path)})
|
||||
|
||||
var params []any
|
||||
segs := strings.Split(strings.Trim(r.Path, "/"), "/")
|
||||
@@ -174,7 +233,7 @@ func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (o
|
||||
}
|
||||
desc, ok := pathParams[key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%s %s: no description of %s in adminapi.PathParams", r.Method, r.Path, key)
|
||||
return nil, fmt.Errorf("%s %s: no description of %s in the path parameters", r.Method, r.Path, key)
|
||||
}
|
||||
params = append(params, obj("name", name, "in", "path", "required", true,
|
||||
"description", desc, "schema", obj("type", "string")))
|
||||
@@ -192,7 +251,7 @@ func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (o
|
||||
p.set("schema", obj("type", t))
|
||||
params = append(params, p)
|
||||
}
|
||||
if r.Method != "GET" && r.Method != "HEAD" {
|
||||
if g.d.Actor && r.Method != "GET" && r.Method != "HEAD" {
|
||||
params = append(params, obj("$ref", "#/components/parameters/Actor"))
|
||||
}
|
||||
if len(params) > 0 {
|
||||
@@ -200,8 +259,12 @@ func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (o
|
||||
}
|
||||
|
||||
if r.Request != nil {
|
||||
ct := "application/json"
|
||||
if r.Form {
|
||||
ct = "multipart/form-data"
|
||||
}
|
||||
body := obj("required", !r.OptionalBody,
|
||||
"content", obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Request)))))
|
||||
"content", obj(ct, obj("schema", g.schema(reflect.TypeOf(r.Request)))))
|
||||
op.set("requestBody", body)
|
||||
}
|
||||
|
||||
@@ -209,10 +272,22 @@ func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (o
|
||||
if status == 0 {
|
||||
status = 200
|
||||
}
|
||||
content := obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Response))))
|
||||
var content object
|
||||
switch {
|
||||
case r.Binary != "":
|
||||
content = obj(r.Binary, obj("schema", obj("type", "string", "format", "binary")))
|
||||
case r.Response == nil:
|
||||
content = obj("application/json", obj("schema", obj()))
|
||||
default:
|
||||
content = obj("application/json", obj("schema", g.schema(reflect.TypeOf(r.Response))))
|
||||
}
|
||||
if r.CSV {
|
||||
content.set("text/csv", obj("schema", obj("type", "string")))
|
||||
}
|
||||
if r.Stream {
|
||||
content.set("text/event-stream", obj("schema", obj("type", "string",
|
||||
"description", "With stream: true, server-sent events: one JSON chunk per data: line, then data: [DONE].")))
|
||||
}
|
||||
desc := "OK"
|
||||
if status == 202 {
|
||||
desc = "Accepted: started, and running on."
|
||||
@@ -226,10 +301,10 @@ func (g *generator) operation(r adminapi.Route, pathParams map[string]string) (o
|
||||
}
|
||||
|
||||
// operationID is the method and the path's words, getModelsNameTarget.
|
||||
func operationID(method, p string) string {
|
||||
func (g *generator) operationID(method, p string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(strings.ToLower(method))
|
||||
for _, s := range strings.Split(strings.TrimPrefix(p, "/api/v1/"), "/") {
|
||||
for _, s := range strings.Split(strings.TrimPrefix(p, g.d.Prefix), "/") {
|
||||
for _, w := range strings.FieldsFunc(s, func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) }) {
|
||||
b.WriteString(strings.ToUpper(w[:1]) + w[1:])
|
||||
}
|
||||
@@ -238,8 +313,8 @@ func operationID(method, p string) string {
|
||||
}
|
||||
|
||||
// tagOf groups an operation by the first word of its path.
|
||||
func tagOf(p string) string {
|
||||
first, _, _ := strings.Cut(strings.TrimPrefix(p, "/api/v1/"), "/")
|
||||
func (g *generator) tagOf(p string) string {
|
||||
first, _, _ := strings.Cut(strings.TrimPrefix(strings.TrimPrefix(p, g.d.Prefix), "/"), "/")
|
||||
return strings.TrimSuffix(first, ".json")
|
||||
}
|
||||
|
||||
@@ -256,13 +331,13 @@ type generator struct {
|
||||
names map[reflect.Type]string
|
||||
schemas map[string]any
|
||||
seen []reflect.Type
|
||||
d Doc
|
||||
}
|
||||
|
||||
var (
|
||||
timeType = reflect.TypeOf(time.Time{})
|
||||
durationType = reflect.TypeOf(time.Duration(0))
|
||||
rawType = reflect.TypeOf(json.RawMessage{})
|
||||
errorType = reflect.TypeOf(adminapi.ErrorView{})
|
||||
marshalerType = reflect.TypeOf((*json.Marshaler)(nil)).Elem()
|
||||
textType = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem()
|
||||
)
|
||||
@@ -294,7 +369,7 @@ func (g *generator) collect(t reflect.Type) {
|
||||
// modelserve.Job. Qualified always rather than only on a collision, so a
|
||||
// type added elsewhere one day cannot rename a schema clients already use.
|
||||
func (g *generator) assignNames() {
|
||||
own := reflect.TypeOf(adminapi.Route{}).PkgPath()
|
||||
own := g.d.Home
|
||||
for _, t := range g.seen {
|
||||
n := t.Name()
|
||||
if t.PkgPath() != own {
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/clientapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
)
|
||||
|
||||
@@ -161,3 +162,39 @@ func TestAPathParameterWithNoDescriptionIsAnError(t *testing.T) {
|
||||
t.Error("a parameter nobody described was let through")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheClientAPIDocumentIsNotStaleAndDescribesWhatClientsSend(t *testing.T) {
|
||||
want, err := GenerateDoc(repoRoot(t), Client, clientapi.Routes, clientapi.PathParams)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile(filepath.Join(repoRoot(t), "docs", "client-api.openapi.json"))
|
||||
if err != nil || string(got) != string(want) {
|
||||
t.Fatal("docs/client-api.openapi.json no longer matches the client API's routes and types.\n" +
|
||||
"Run: go generate ./internal/docgen")
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(want, &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chat := at(t, doc, "paths", "/v1/chat/completions", "post")
|
||||
if at(t, chat, "requestBody", "content", "application/json", "schema", "$ref") != "#/components/schemas/ChatCompletionRequest" {
|
||||
t.Error("chat's body is not its request type")
|
||||
}
|
||||
if _, ok := at(t, chat, "responses", "200", "content").(map[string]any)["text/event-stream"]; !ok {
|
||||
t.Error("chat does not say it may stream")
|
||||
}
|
||||
if _, ok := at(t, doc, "paths", "/v1/files", "post", "requestBody", "content").(map[string]any)["multipart/form-data"]; !ok {
|
||||
t.Error("an upload is not a form")
|
||||
}
|
||||
if at(t, doc, "paths", "/v1/3d/generations", "post", "responses", "202", "description") == nil {
|
||||
t.Error("a 3D job is not answered 202")
|
||||
}
|
||||
if _, ok := at(t, doc, "components", "securitySchemes").(map[string]any)["apiKey"]; !ok {
|
||||
t.Error("no API key scheme")
|
||||
}
|
||||
// The admin API's actor header is the admin API's.
|
||||
if _, ok := at(t, doc, "components").(map[string]any)["parameters"]; ok {
|
||||
t.Error("the client API takes the admin API's X-Llmgw-Actor")
|
||||
}
|
||||
}
|
||||
@@ -70,8 +70,11 @@ var Reference = []Block{
|
||||
{Package: "catalog", Name: "ComfyUI", Title: "ComfyUI", Doc: "", Fields: []Field{
|
||||
{Key: "workflow", Type: "string", Doc: "Workflow is a built-in's name (flux-schnell, the default, or\nflux-dev) or a JSON file on the control plane, in ComfyUI's API\nformat.", Nested: ""},
|
||||
{Key: "checkpoint", Type: "string", Doc: "Checkpoint is the file the workflow's {{checkpoint}} names: the\nsource's own name unless set, which a source that is a set of files\nmust.", Nested: ""},
|
||||
{Key: "output", Type: "string", Doc: "Output is what the workflow makes: pictures (the default), asked for\nat /v1/images/generations, or meshes, asked for as a job at\n/v1/3d/generations from a picture.", Nested: ""},
|
||||
{Key: "output", Type: "string", Doc: "Output is what the workflow makes: pictures (the default), asked for\nat /v1/images/generations; meshes, asked for as a job at\n/v1/3d/generations from a picture; or video, audio or files. Every\nkind is asked for as a job at /v1/workflows/runs too.", Nested: ""},
|
||||
{Key: "inputs", Type: "[]Input", Doc: "Inputs are what the workflow takes from a request at\n/v1/workflows/runs, each the placeholder of its name.", Nested: ""},
|
||||
{Key: "nodes", Type: "[]CustomNode", Doc: "Nodes are custom nodes the workflow needs, put in each replica's own\ncustom_nodes at the commit named.", Nested: ""},
|
||||
{Key: "huggingface_repo", Type: "string", Doc: "HuggingFaceRepo is the repository a custom node loads the weights by\nname from (Stable Fast 3D's loader asks for stabilityai/stable-fast-3d):\nthe replica's weights are shown to the node there, in the Hugging Face\ncache of the engines' user, so nothing is fetched and no token is\nneeded when it runs.", Nested: ""},
|
||||
{Key: "environment", Type: "string", Doc: "Environment names the models that may share one ComfyUI replica: every\nmodel of an environment agrees on the custom nodes they both name, so\none replica can carry all of them and ComfyUI moves between their\nweights itself. Empty is an environment of the model alone.", Nested: ""},
|
||||
}},
|
||||
{Package: "engines", Name: "ComfyUIConfig", Title: "ComfyUIConfig", Doc: "", Fields: []Field{
|
||||
{Key: "dir", Type: "string", Doc: "Dir is ComfyUI's directory, where main.py is. Readable by the user\nengines run as.", Nested: ""},
|
||||
@@ -94,6 +97,7 @@ var Reference = []Block{
|
||||
{Key: "repo", Type: "string", Doc: "Repo is the repository, https only.", Nested: ""},
|
||||
{Key: "commit", Type: "string", Doc: "Commit is the full commit the node is used at: a branch moves, and a\nnode that changed under a model is a model that changed unasked.", Nested: ""},
|
||||
{Key: "requirements", Type: "[]string", Doc: "Requirements are the PyPI packages the node needs, by name and version\nrange only. They are installed beside the replica, never into\nComfyUI's environment: a package named here at the range named, and\nevery other one at the version ComfyUI already runs. One named here\nis first on ComfyUI's path, so its range must suit ComfyUI too.", Nested: ""},
|
||||
{Key: "build", Type: "[]string", Doc: "Build are directories of the repository that are Python packages to\nbe compiled rather than downloaded: C++ and CUDA extensions no index\nhas (Stable Fast 3D's texture_baker and uv_unwrapper). Each is built\nas a wheel with ComfyUI's Python for the card the replica runs on,\nkept on the node for the next replica on the same kind of card, and\ninstalled beside the replica like its requirements.", Nested: ""},
|
||||
}},
|
||||
{Package: "config", Name: "Endpoint", Title: "Endpoint", Doc: "", Fields: []Field{
|
||||
{Key: "id", Type: "string", Doc: "", Nested: ""},
|
||||
@@ -146,10 +150,25 @@ var Reference = []Block{
|
||||
{Package: "config", Name: "FirewallSettings", Title: "FirewallSettings", Doc: "", Fields: []Field{
|
||||
{Key: "enabled", Type: "bool", Doc: "Enabled keeps llmgw's internal ports — engines, node exporter, the\nagents' stream, the mirror, the store — to the cluster's networks on\nevery machine. False removes llmgw's nftables table everywhere, for a\nsite whose own firewall does that job; the rules are kept, and true\nputs them back. Changed in the cluster's settings, it is in force on\nevery node and control plane within seconds.", Nested: ""},
|
||||
}},
|
||||
{Package: "catalog", Name: "GPUNeed", Title: "GPUNeed", Doc: "", Fields: []Field{
|
||||
{Key: "min_compute_capability", Type: "float64", Doc: "MinComputeCapability is the oldest architecture that runs it: 7.5 is\nTuring, 8.0 Ampere (bf16, flash-attention 2), 8.9 Ada (fp8), 9.0 Hopper.", Nested: ""},
|
||||
{Key: "min_memory_gb", Type: "int", Doc: "MinMemoryGB is the card's own memory it needs.", Nested: ""},
|
||||
}},
|
||||
{Package: "catalog", Name: "GuardModel", Title: "GuardModel", Doc: "", Fields: []Field{
|
||||
{Key: "format", Type: "string", Doc: "Format is qwen3guard (Qwen3Guard-Gen: \"Safety: Safe\", \"Unsafe\" or\n\"Controversial\", and the categories), llamaguard3 (Meta's Llama Guard\n3: \"safe\", or \"unsafe\" and the categories), shieldgemma (Google's\nShieldGemma: Yes or No), or json ({\"safe\": false, \"categories\":\n[\"...\"]}).", Nested: ""},
|
||||
{Key: "categories", Type: "[]string", Doc: "Categories the model is asked about, for shieldgemma — each a policy\nsentence — and the names reported for llamaguard3's S1, S2, ….", Nested: ""},
|
||||
}},
|
||||
{Package: "catalog", Name: "Input", Title: "Input", Doc: "", Fields: []Field{
|
||||
{Key: "name", Type: "string", Doc: "Name is the placeholder's, and the request's key.", Nested: ""},
|
||||
{Key: "type", Type: "string", Doc: "Type is one of text, int, float, bool, choice, image, mask, audio,\nvideo, file. The last five are files, sent as base64, a data URL, or\nthe id of a file uploaded to /v1/files, and put in ComfyUI's input\ndirectory; the placeholder gets the name they were given there.", Nested: ""},
|
||||
{Key: "required", Type: "bool", Doc: "Required inputs must be in every request; the others take Default,\nor the workflow's own {{name|default}}.", Nested: ""},
|
||||
{Key: "default", Type: "any", Doc: "", Nested: ""},
|
||||
{Key: "min", Type: "*float64", Doc: "Min and Max bound a number; for text, Max bounds its length.", Nested: ""},
|
||||
{Key: "max", Type: "*float64", Doc: "", Nested: ""},
|
||||
{Key: "choices", Type: "[]string", Doc: "Choices are what a choice may be.", Nested: ""},
|
||||
{Key: "max_mb", Type: "int", Doc: "MaxMB bounds a file input; zero is the type's own bound.", Nested: ""},
|
||||
{Key: "doc", Type: "string", Doc: "Doc says what the input is for, to whoever fills it in.", Nested: ""},
|
||||
}},
|
||||
{Package: "guard", Name: "JudgePolicy", Title: "JudgePolicy", Doc: "", Fields: []Field{
|
||||
{Key: "model", Type: "string", Doc: "Model is a catalog model with role: guard.", Nested: ""},
|
||||
{Key: "models", Type: "[]string", Doc: "Models are judges asked in turn, instead of Model: the first that\nanswers gives the verdict. A judge on a GPU first and one on CPUs\nafter it is the GPU's speed while it is there, and the CPUs' when it\nis not — busy, failed, or the node down.", Nested: ""},
|
||||
@@ -171,9 +190,14 @@ var Reference = []Block{
|
||||
{Key: "docs_sign_in", Type: "bool", Doc: "DocsSignIn serves the docs portal at /docs only to someone signed in.\nOff, the portal is public like this page: its client guide is for the\nperson who has no account yet, and the operator documents describe\nllmgw in general — no address, model or key of this installation —\nwhich the project's repository publishes anyway. On, a visitor is sent\nto sign in, and without accounts the portal needs an API key.", Nested: ""},
|
||||
}},
|
||||
{Package: "catalog", Name: "License", Title: "License", Doc: "", Fields: []Field{
|
||||
{Key: "id", Type: "string", Doc: "ID names the licence for a machine: an SPDX identifier where there is\none (MIT, Apache-2.0, CC-BY-NC-4.0), else a lowercase name of the\nlicence's own (stability-community, tencent-hunyuan-community). What a\nperson's or a site's choice of licences matches on. Name is for people.", Nested: ""},
|
||||
{Key: "name", Type: "string", Doc: "Name is the licence's own: MIT, Apache-2.0.", Nested: ""},
|
||||
{Key: "url", Type: "string", Doc: "", Nested: ""},
|
||||
{Key: "notice", Type: "string", Doc: "Notice is what must accompany what was made: attribution, terms on\nthe output, what the weights were trained on.", Nested: ""},
|
||||
{Key: "commercial", Type: "*bool", Doc: "Commercial says whether what is made may be used commercially: nil\nis not said, which is not the same as yes.", Nested: ""},
|
||||
{Key: "excluded_regions", Type: "[]string", Doc: "ExcludedRegions are where the licence grants nothing: ISO 3166 codes,\nand EU for the union (Hunyuan's: EU, UK, KR).", Nested: ""},
|
||||
{Key: "revenue_cap_usd", Type: "int64", Doc: "RevenueCapUSD is the yearly revenue above which the licence asks for\nanother agreement (Stability's community licence: 1000000). Zero is\nnone.", Nested: ""},
|
||||
{Key: "attribution", Type: "string", Doc: "Attribution is what a product using the output must show: \"Powered by\nStability AI\".", Nested: ""},
|
||||
}},
|
||||
{Package: "engines", Name: "LlamaCppConfig", Title: "LlamaCppConfig", Doc: "", Fields: []Field{
|
||||
{Key: "bin", Type: "string", Doc: "Bin is the llama-server binary. It is the server, not the CLI: the\ngateway talks OpenAI over HTTP and needs the one that serves it.\nEmpty means the agent installs llama.cpp itself, at the version the\ncontrol plane asks for. Setting it opts out of that.", Nested: ""},
|
||||
@@ -289,6 +313,9 @@ var Reference = []Block{
|
||||
{Key: "balance_abs", Type: "float64", Doc: "", Nested: ""},
|
||||
{Key: "balance_rel", Type: "float64", Doc: "", Nested: ""},
|
||||
}},
|
||||
{Package: "config", Name: "PresetSettings", Title: "PresetSettings", Doc: "", Fields: []Field{
|
||||
{Key: "auto", Type: "bool", Doc: "Auto has the leading control plane add a picture model (flux-schnell)\nand a 3D model (triposr) by itself, on demand, once a node has a card\nthat fits one and the catalog has no model that makes the same kind of\nthing — so a new cluster's portal has its pictures and 3D pages\nwithout anybody writing a definition. Only where the cluster already\ninstalls ComfyUI (engines.comfyui). A preset somebody removed is not\nadded again by itself. In force on the leader within a minute.", Nested: ""},
|
||||
}},
|
||||
{Package: "catalog", Name: "PreviousOrigin", Title: "PreviousOrigin", Doc: "", Fields: []Field{
|
||||
{Key: "source", Type: "string", Doc: "", Nested: ""},
|
||||
{Key: "revision", Type: "string", Doc: "", Nested: ""},
|
||||
@@ -316,6 +343,13 @@ var Reference = []Block{
|
||||
{Key: "priority", Type: "int", Doc: "Priority orders profiles during placement; highest that fits wins.", Nested: ""},
|
||||
{Key: "startup_timeout", Type: "time.Duration", Doc: "StartupTimeout bounds model loading. A 70B model across two nodes takes\ntens of minutes, so this is per profile rather than global.", Nested: ""},
|
||||
{Key: "kv_events", Type: "bool", Doc: "KVEvents has vLLM publish what its prefix cache holds, and the router\nroute this model's requests by it: to the replica holding the most of\na prompt's leading blocks (plan §7.5 tier 2). vLLM only.\n\nOff by default, because it costs something on every request: the\nrouter asks a replica to tokenize the prompt before placing it, a\nround trip and the chat template run twice. It pays where prompts\nshare long prefixes that the approximate tree guesses wrong — many\nreplicas, conversations that move between them, evictions. The engine\nis started with sha256_cbor block hashing and a fixed PYTHONHASHSEED,\nwhich the router's hashing assumes; arguments overriding either leave\nthe index empty, and the agent says so in its log.", Nested: ""},
|
||||
{Key: "gpu", Type: "*GPUNeed", Doc: "GPU says what card the profile needs, in place of naming its class:\nit stands for every class in the cluster that meets it. A catalog\nentry written once then places sensibly on any site's cards.", Nested: "catalog.GPUNeed"},
|
||||
{Key: "comfyui", Type: "*ProfileComfyUI", Doc: "ComfyUI is this profile's own workflow, checkpoint and nodes, over the\nmodel's: an fp8 workflow where the card has fp8, fp16 where it has not.", Nested: "catalog.ProfileComfyUI"},
|
||||
}},
|
||||
{Package: "catalog", Name: "ProfileComfyUI", Title: "ProfileComfyUI", Doc: "", Fields: []Field{
|
||||
{Key: "workflow", Type: "string", Doc: "", Nested: ""},
|
||||
{Key: "checkpoint", Type: "string", Doc: "", Nested: ""},
|
||||
{Key: "nodes", Type: "[]CustomNode", Doc: "", Nested: ""},
|
||||
}},
|
||||
{Package: "config", Name: "Router", Title: "Router", Doc: "", Fields: []Field{
|
||||
{Key: "activation_wait", Type: "time.Duration", Doc: "ActivationWait is how long a request waits for an on-demand model to\nload before the client is told to come back. Zero takes the default.\n\nThe one real decision in on-demand loading. Refusing outright makes the\nfeature useless, since the first request for every model would fail;\nwaiting without a bound turns a request into a job, and a client that\nbudgeted thirty seconds ends up holding a connection for ten minutes.", Nested: ""},
|
||||
@@ -407,6 +441,7 @@ var Reference = []Block{
|
||||
{Key: "mail", Type: "mail.Config", Doc: "Mail is where the gateway's few emails go out from: a link to set a\nforgotten password. Unset, none is sent and people ask an operator.", Nested: "mail.Config"},
|
||||
{Key: "serving", Type: "ServingSettings", Doc: "Serving stops and starts the whole cluster's service (llmgw stop, llmgw\nstart).", Nested: "config.ServingSettings"},
|
||||
{Key: "firewall", Type: "FirewallSettings", Doc: "Firewall turns llmgw's firewall on every machine on or off.", Nested: "config.FirewallSettings"},
|
||||
{Key: "presets", Type: "PresetSettings", Doc: "Presets is how the models that come with llmgw reach the catalog.", Nested: "config.PresetSettings"},
|
||||
{Key: "agents", Type: "AgentSettings", Doc: "Agents are the nodes' agent settings: agent.yaml's keys but those an\nagent needs to start, for every node (default) and for one\n(nodes.<id>). Sent to the agents as they change; an agent whose\nsettings changed starts again on them, its engines serving\nthroughout.", Nested: "config.AgentSettings"},
|
||||
{Key: "firewall_networks", Type: "[]string", Doc: "FirewallNetworks are networks, besides those each machine reaches the\ncluster on, that llmgw's internal ports answer: a monitoring host on\nanother subnet, nodes behind a router. Written into every machine's\nfirewall by `server init`, `node add` and `server add`.", Nested: ""},
|
||||
{Key: "backup_recipients", Type: "[]string", Doc: "BackupRecipients encrypt every archive `llmgw server backup` writes, to\nwhoever holds one of their keys: age recipients (age1…) or SSH public\nkeys (ssh-ed25519 …, ssh-rsa …), as `age` takes them. An archive holds\nthe certificate authority's key and every secret in the store, and is\ncopied off the machine; encrypted, the copy is safe wherever it lands.\nEmpty writes archives unencrypted, readable by root only.", Nested: ""},
|
||||
|
||||
@@ -26,6 +26,8 @@ var Sources = []string{
|
||||
"internal/agent/supervisor/supervisor.go",
|
||||
"internal/tlsconf/tlsconf.go",
|
||||
"internal/control/catalog/catalog.go",
|
||||
"internal/control/catalog/workflow.go",
|
||||
"internal/control/catalog/hardware.go",
|
||||
"internal/router/admission/controller.go",
|
||||
"internal/accounting/cost.go",
|
||||
"internal/guard/policy.go",
|
||||
@@ -33,6 +35,7 @@ var Sources = []string{
|
||||
"internal/config/oidc.go",
|
||||
"internal/config/firewall.go",
|
||||
"internal/config/serving.go",
|
||||
"internal/config/presets.go",
|
||||
"internal/config/agents.go",
|
||||
"internal/mail/mail.go",
|
||||
"internal/notify/notify.go",
|
||||
|
||||
@@ -2,6 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
|
||||
@@ -26,6 +27,40 @@ type unservable interface {
|
||||
Unservable(model string) (string, bool)
|
||||
}
|
||||
|
||||
// waiter is an activator that can say which model holds the cards an
|
||||
// on-demand one waits for, and about how long the wait is.
|
||||
type waiter interface {
|
||||
WaitFor(model string) (heldBy string, eta time.Duration, ok bool)
|
||||
}
|
||||
|
||||
// waitingErr is errLoading for a model waiting on another to give up its
|
||||
// cards: who holds them, and the wait when it can be told.
|
||||
type waitingErr struct {
|
||||
heldBy string
|
||||
eta time.Duration
|
||||
}
|
||||
|
||||
func (e *waitingErr) Error() string {
|
||||
if e.eta > 0 {
|
||||
return fmt.Sprintf("%s: waiting for %s to give up the GPU; expected in about %d s",
|
||||
errLoading, e.heldBy, int(e.eta.Seconds()))
|
||||
}
|
||||
return fmt.Sprintf("%s: waiting for %s to give up the GPU", errLoading, e.heldBy)
|
||||
}
|
||||
|
||||
func (e *waitingErr) Unwrap() error { return errLoading }
|
||||
|
||||
// loadingErr is why a wanted model is not serving yet: waiting on another
|
||||
// model's cards when the activator can say so, else simply loading.
|
||||
func (r *Router) loadingErr(model string) error {
|
||||
if w, ok := r.activator.(waiter); ok {
|
||||
if by, eta, waiting := w.WaitFor(model); waiting {
|
||||
return &waitingErr{heldBy: by, eta: eta}
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%w: %s", errLoading, "the model is loading; retry shortly")
|
||||
}
|
||||
|
||||
// whyUnservable is the activator's reason a model cannot be served, if any.
|
||||
func (r *Router) whyUnservable(model string) (string, bool) {
|
||||
u, ok := r.activator.(unservable)
|
||||
|
||||
@@ -39,7 +39,7 @@ func (r *Router) runBatches(ctx context.Context) {
|
||||
t := time.NewTicker(batchPoll)
|
||||
defer t.Stop()
|
||||
for {
|
||||
for {
|
||||
for r.leading() {
|
||||
now := time.Now()
|
||||
b, ok, err := r.store.ClaimBatch(ctx, owner, now, now.Add(batchLease))
|
||||
if err != nil {
|
||||
|
||||
@@ -58,6 +58,9 @@ func (r *Router) registerChat(mux *http.ServeMux, pages *web.AccountPages) {
|
||||
}))
|
||||
r.registerImagesPage(mux, pages)
|
||||
r.registerMeshesPage(mux, pages)
|
||||
if r.store != nil {
|
||||
pages.SetLicences(r.licenceChoices, r.store)
|
||||
}
|
||||
}
|
||||
|
||||
// chatSend dispatches one turn on behalf of a signed-in person.
|
||||
|
||||
@@ -103,3 +103,21 @@ func TestStructuredContentIsCounted(t *testing.T) {
|
||||
t.Errorf("structured content counted as %d bytes", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPictureIsCountedAsWhatAModelMakesOfItNotItsBase64(t *testing.T) {
|
||||
// A 1280x720 PNG was counted as 138 thousand tokens of base64 and an 8k
|
||||
// vision model refused it; its projector makes about a thousand of it.
|
||||
pic := `{"type":"image_url","image_url":{"url":"data:image/png;base64,` + strings.Repeat("A", 550_000) + `"}}`
|
||||
b := &inferenceBody{Messages: []struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
}{{Role: "user", Content: json.RawMessage(`[{"type":"text","text":"check it"},` + pic + `,` + pic + `]`)}}}
|
||||
got := promptBytes(b) / 4
|
||||
if got < 2000 || got > 2200 {
|
||||
t.Errorf("two pictures and a line estimated as %d tokens", got)
|
||||
}
|
||||
m := catalog.Model{Name: "vlm", ContextLength: 8192}
|
||||
if why := checkContextFits(m, got, 300); why != "" {
|
||||
t.Errorf("refused: %s", why)
|
||||
}
|
||||
}
|
||||
+119
-6
@@ -8,7 +8,9 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
llmgw "gitea.mynodes.xyz/ilker/llmgw"
|
||||
"io"
|
||||
"maps"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
@@ -119,6 +121,12 @@ func (r *Router) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /v1/messages", r.handleMessages)
|
||||
mux.HandleFunc("POST /v1/messages/count_tokens", r.handleCountTokens)
|
||||
mux.HandleFunc("GET /v1/models", r.handleModels)
|
||||
// The API described, for SDK generators and the portal's reference.
|
||||
// Not secret, and asked for before a key is in hand: no key needed.
|
||||
mux.HandleFunc("GET /v1/openapi.json", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(llmgw.ClientOpenAPI)
|
||||
})
|
||||
// Files and batches: a file of requests served at the lowest priority.
|
||||
mux.HandleFunc("POST /v1/files", r.handleUploadFile)
|
||||
mux.HandleFunc("GET /v1/files", r.handleListFiles)
|
||||
@@ -135,6 +143,12 @@ func (r *Router) Handler() http.Handler {
|
||||
mux.HandleFunc("GET "+meshesPath, r.handleListJobs)
|
||||
mux.HandleFunc("GET "+meshesPath+"/{id}", r.handleGetJob)
|
||||
mux.HandleFunc("POST "+meshesPath+"/{id}/cancel", r.handleCancelJob)
|
||||
// Workflow runs: whatever a ComfyUI model makes, from the inputs its
|
||||
// catalog entry declares, as a job like a 3D model.
|
||||
mux.HandleFunc("POST "+workflowsPath, r.createJobAt(workflowsPath))
|
||||
mux.HandleFunc("GET "+workflowsPath, r.handleListJobs)
|
||||
mux.HandleFunc("GET "+workflowsPath+"/{id}", r.handleGetJob)
|
||||
mux.HandleFunc("POST "+workflowsPath+"/{id}/cancel", r.handleCancelJob)
|
||||
// Knowledge bases: documents a person's llmgw-kb read and embedded.
|
||||
r.registerKB(mux)
|
||||
// Liveness: this process is up. It says nothing about whether it can
|
||||
@@ -270,6 +284,17 @@ func (r *Router) Handler() http.Handler {
|
||||
docs := pages.DocsPage(web.Docs(version.Version, true), r.landing.DocsSignIn)
|
||||
mux.HandleFunc("GET /docs", docs)
|
||||
mux.HandleFunc("GET /docs/{doc}", docs)
|
||||
// The APIs' reference, in Swagger UI: the client API's document to
|
||||
// whoever may read the docs, the admin API's to staff.
|
||||
mux.HandleFunc("GET /docs/api", pages.DocsPage(web.APIDocs(version.Version, true), r.landing.DocsSignIn))
|
||||
mux.HandleFunc("GET /docs/api/admin.json", pages.Guard(func(w http.ResponseWriter, req *http.Request, user store.User) {
|
||||
if !user.Staff() {
|
||||
http.NotFound(w, req)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(llmgw.AdminOpenAPI)
|
||||
}))
|
||||
r.registerChat(mux, pages)
|
||||
// An organization's own page, for the people who manage it.
|
||||
pages.RegisterOrgPages(mux, r.store, r.acct)
|
||||
@@ -304,6 +329,10 @@ func (r *Router) Handler() http.Handler {
|
||||
if e, ok := r.clusterView.(web.NodeEngineAdmin); ok {
|
||||
pages.RegisterNodeEngines(mux, e)
|
||||
}
|
||||
// The models that come with llmgw, added from the same page.
|
||||
if a, ok := r.clusterView.(web.PresetAdmin); ok {
|
||||
pages.RegisterPresets(mux, a)
|
||||
}
|
||||
pages.RegisterAdminQuotas(mux, r.store, r.acct)
|
||||
// And the report of what that tree actually used.
|
||||
if r.history != nil {
|
||||
@@ -326,12 +355,13 @@ func (r *Router) Handler() http.Handler {
|
||||
docs := web.Docs(version.Version, false)
|
||||
mux.HandleFunc("GET /docs", docs)
|
||||
mux.HandleFunc("GET /docs/{doc}", docs)
|
||||
mux.HandleFunc("GET /docs/api", web.APIDocs(version.Version, false))
|
||||
}
|
||||
|
||||
if r.auth == nil {
|
||||
return r.refuseWhileStopped(r.refuseLoops(mux))
|
||||
}
|
||||
exempt := []string{"/health", "/ready", "/leader", "/", "/about", "/_static/", "/_lang/", "/claim/",
|
||||
exempt := []string{"/health", "/ready", "/leader", "/v1/openapi.json", "/", "/about", "/_static/", "/_lang/", "/claim/",
|
||||
"/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/",
|
||||
"/admin", "/admin/", "/chat", "/chat/", "/images", "/images/", "/3d", "/3d/", "/org", "/org/", "/setup/"}
|
||||
// The docs are public unless the site said otherwise, and then guarded by
|
||||
@@ -520,7 +550,7 @@ func (r *Router) handleInference(path string) http.HandlerFunc {
|
||||
}
|
||||
if p, ok := auth.FromContext(req.Context()); ok {
|
||||
if !r.permits(p, parsed.Model) {
|
||||
auth.WriteForbiddenModel(w, parsed.Model)
|
||||
r.writeForbidden(w, p, parsed.Model)
|
||||
r.m.observeRequest(r.metricModel(parsed.Model), "", http.StatusForbidden, time.Since(start), nil, 0)
|
||||
return
|
||||
}
|
||||
@@ -676,6 +706,11 @@ func (r *Router) permits(p auth.Principal, requested string) bool {
|
||||
return true
|
||||
}
|
||||
canonical, m := r.resolve(requested)
|
||||
// A licence the person has said not to use leaves the model out
|
||||
// whatever the allowlist says: it is theirs to narrow, not to widen.
|
||||
if licenceExcluded(p.Limits.ExcludedLicences, m) != "" {
|
||||
return false
|
||||
}
|
||||
if p.Allows(canonical) {
|
||||
return true
|
||||
}
|
||||
@@ -710,7 +745,19 @@ func (r *Router) finish(w http.ResponseWriter, req *parsedRequest, res dispatchR
|
||||
// is told so will make it. Without the header a well-behaved client
|
||||
// backs off exactly as it would from a permanent failure.
|
||||
status = http.StatusServiceUnavailable
|
||||
w.Header().Set("Retry-After", "30")
|
||||
retry := 30
|
||||
// Waiting on another model's cards: who holds them and for about
|
||||
// how long, which a job keeps as its progress.
|
||||
var we *waitingErr
|
||||
if errors.As(res.err, &we) {
|
||||
w.Header().Set(waitingForHeader, we.heldBy)
|
||||
if we.eta > 0 {
|
||||
secs := int(we.eta.Seconds())
|
||||
w.Header().Set(etaHeader, strconv.Itoa(secs))
|
||||
retry = min(max(secs, 5), retry)
|
||||
}
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retry))
|
||||
writeError(w, status, "server_error", res.err.Error())
|
||||
case errors.Is(res.err, errNoCapacity):
|
||||
status = http.StatusServiceUnavailable
|
||||
@@ -954,6 +1001,23 @@ func (p *parsedRequest) contextTokens() int {
|
||||
return p.PromptTokens
|
||||
}
|
||||
|
||||
// mediaPartBytes is what one picture or sound in a prompt is counted as,
|
||||
// in the bytes-over-four estimate: a thousand tokens, about what a vision
|
||||
// model's projector makes of a 1280x720 picture. Engines differ, and a large
|
||||
// picture can be more; the estimate errs low, as it does for text, and the
|
||||
// engine has the last word.
|
||||
const mediaPartBytes = 1024 * 4
|
||||
|
||||
// promptBytesOf is promptBytes of a request body; the body's length when it
|
||||
// cannot be read as one.
|
||||
func promptBytesOf(body []byte) int {
|
||||
var b inferenceBody
|
||||
if err := json.Unmarshal(body, &b); err != nil {
|
||||
return len(body)
|
||||
}
|
||||
return promptBytes(&b)
|
||||
}
|
||||
|
||||
func promptBytes(b *inferenceBody) int {
|
||||
n := 0
|
||||
add := func(raw json.RawMessage) {
|
||||
@@ -965,9 +1029,31 @@ func promptBytes(b *inferenceBody) int {
|
||||
n += len(str)
|
||||
return
|
||||
}
|
||||
// Not a plain string: structured content, an array of parts, an image.
|
||||
// The raw JSON overstates the text slightly, which is the safe
|
||||
// direction for a limit.
|
||||
// An array of parts: text by its text, a picture or a sound by
|
||||
// what an engine makes of one. Counted as raw JSON, a 1280x720 PNG
|
||||
// was 138 thousand "tokens" of base64 and refused by an 8k model
|
||||
// that takes it as about one thousand.
|
||||
var parts []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &parts); err == nil {
|
||||
for _, part := range parts {
|
||||
var typ string
|
||||
_ = json.Unmarshal(part["type"], &typ)
|
||||
switch typ {
|
||||
case "text", "input_text":
|
||||
var t string
|
||||
_ = json.Unmarshal(part["text"], &t)
|
||||
n += len(t)
|
||||
case "image_url", "input_image", "image", "input_audio":
|
||||
n += mediaPartBytes
|
||||
default:
|
||||
b, _ := json.Marshal(part)
|
||||
n += len(b)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// Anything else structured: the raw JSON overstates the text
|
||||
// slightly, which is the safe direction for a limit.
|
||||
n += len(raw)
|
||||
}
|
||||
for _, m := range b.Messages {
|
||||
@@ -1089,6 +1175,16 @@ func (r *Router) handleModels(w http.ResponseWriter, req *http.Request) {
|
||||
seen[m] = struct{}{}
|
||||
}
|
||||
}
|
||||
// A model that loads when asked has no replica until somebody asks,
|
||||
// and asking is how it gets one: listing only what runs hid it from
|
||||
// exactly the clients that would have asked — a 3D model, a picture
|
||||
// model, asleep between uses. Said with loaded: false.
|
||||
loaded := maps.Clone(seen)
|
||||
for name, m := range r.modelMap() {
|
||||
if name == m.Name && m.Scaling.OnDemand() && !m.IsExternal() {
|
||||
seen[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
// Catalog aliases are usable names too, so clients should see them.
|
||||
for name := range r.modelMap() {
|
||||
if _, ok := seen[r.modelMap()[name].Name]; ok {
|
||||
@@ -1116,8 +1212,15 @@ func (r *Router) handleModels(w http.ResponseWriter, req *http.Request) {
|
||||
if r.modelMap()[n].Simulated() {
|
||||
owner = "llmgw-sim"
|
||||
}
|
||||
_, up := loaded[n]
|
||||
if m := r.modelMap()[n]; !up && m.Name != "" {
|
||||
_, up = loaded[m.Name]
|
||||
}
|
||||
entry := map[string]any{
|
||||
"id": n, "object": "model", "created": time.Now().Unix(), "owned_by": owner,
|
||||
// Not OpenAI's: whether a replica is up now, or the first
|
||||
// request waits for one to load.
|
||||
"loaded": up,
|
||||
}
|
||||
// Not OpenAI's either: what a federated peer reading this list needs
|
||||
// to leave out — a second name for a model already listed, and a
|
||||
@@ -1149,6 +1252,16 @@ func (r *Router) handleModels(w http.ResponseWriter, req *http.Request) {
|
||||
entry["capabilities"] = map[string]bool{
|
||||
"tools": m.Capabilities.Tools, "vision": m.Capabilities.Vision,
|
||||
"embeddings": m.Capabilities.Embeddings, "images": m.MakesImages(), "meshes": m.MakesMeshes(),
|
||||
"workflows": m.RunsWorkflows(),
|
||||
}
|
||||
// What a run of it takes and makes, so a client can build the form
|
||||
// without the catalog in hand.
|
||||
if m.RunsWorkflows() {
|
||||
var inputs any = m.ComfyInputs()
|
||||
if len(m.ComfyInputs()) == 0 {
|
||||
inputs = []any{}
|
||||
}
|
||||
entry["workflow"] = map[string]any{"output": m.ComfyOutput(), "inputs": inputs}
|
||||
}
|
||||
data = append(data, entry)
|
||||
}
|
||||
|
||||
@@ -112,6 +112,18 @@ func parseImageRequest(body []byte) (imageRequest, error) {
|
||||
// endpoint may serve as it likes.
|
||||
func (r *Router) imageRouteRefusal(path, model string, body []byte) string {
|
||||
m, known := r.modelMap()[model]
|
||||
if path == workflowsPath {
|
||||
if !known {
|
||||
return ""
|
||||
}
|
||||
if !m.RunsWorkflows() {
|
||||
return fmt.Sprintf("%s runs no ComfyUI workflow", m.Name)
|
||||
}
|
||||
if _, err := parseRunRequest(m, body); err != nil {
|
||||
return err.Error()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if path == meshesPath {
|
||||
if known && !m.MakesMeshes() {
|
||||
if m.MakesImages() {
|
||||
@@ -137,6 +149,9 @@ func (r *Router) imageRouteRefusal(path, model string, body []byte) string {
|
||||
return ""
|
||||
}
|
||||
if known && m.ServesComfyUI() {
|
||||
if !m.MakesMeshes() && !m.MakesImages() {
|
||||
return fmt.Sprintf("%s makes %s: ask for it at %s, as a job", m.Name, m.ComfyOutput(), workflowsPath)
|
||||
}
|
||||
if m.MakesMeshes() {
|
||||
return fmt.Sprintf("%s makes 3D models: ask for one at %s, as a job", m.Name, meshesPath)
|
||||
}
|
||||
@@ -149,11 +164,15 @@ func (r *Router) imageRouteRefusal(path, model string, body []byte) string {
|
||||
// proxy would: a failure of this replica that another may not have is
|
||||
// retryable, and nothing is written until the pictures are all in hand.
|
||||
func (r *Router) generateImages(ctx context.Context, w http.ResponseWriter, req *parsedRequest, ep endpoint.Info, body []byte) (proxy.Outcome, error) {
|
||||
if req.Path == meshesPath {
|
||||
switch req.Path {
|
||||
case meshesPath:
|
||||
return r.generateMeshes(ctx, w, req, ep, body)
|
||||
}
|
||||
if req.Path != imagesPath {
|
||||
return proxy.Outcome{}, fmt.Errorf("%s is a ComfyUI server: it answers %s and %s only", ep.ID, imagesPath, meshesPath)
|
||||
case workflowsPath:
|
||||
return r.generateRun(ctx, w, req, ep, body)
|
||||
case imagesPath:
|
||||
default:
|
||||
return proxy.Outcome{}, fmt.Errorf("%s is a ComfyUI server: it answers %s, %s and %s only", ep.ID,
|
||||
imagesPath, meshesPath, workflowsPath)
|
||||
}
|
||||
in, err := parseImageRequest(body)
|
||||
if err != nil {
|
||||
|
||||
+26
-8
@@ -11,7 +11,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
@@ -66,11 +65,21 @@ func (r *Router) jobJSON(ctx context.Context, j store.Job) map[string]any {
|
||||
return out
|
||||
}
|
||||
|
||||
// handleCreateJob queues a request for a 3D model. Refused now: no key, a
|
||||
// model that is not there or not allowed, a request the model cannot serve.
|
||||
// Refused when it is served, and said as the job's error: limits, the
|
||||
// guardrails, a model that will not come up.
|
||||
// handleCreateJob queues a request for a 3D model.
|
||||
func (r *Router) handleCreateJob(w http.ResponseWriter, req *http.Request) {
|
||||
r.createJobAt(meshesPath)(w, req)
|
||||
}
|
||||
|
||||
// createJobAt queues a request at path: a 3D model, a workflow run. Refused
|
||||
// now: no key, a model that is not there or not allowed, a request the model
|
||||
// cannot serve, a file it names that is not the caller's. Refused when it is
|
||||
// served, and said as the job's error: limits, the guardrails, a model that
|
||||
// will not come up.
|
||||
func (r *Router) createJobAt(path string) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, req *http.Request) { r.createJob(w, req, path) }
|
||||
}
|
||||
|
||||
func (r *Router) createJob(w http.ResponseWriter, req *http.Request, path string) {
|
||||
p, ok := r.batchPrincipal(w, req)
|
||||
if !ok {
|
||||
return
|
||||
@@ -97,15 +106,24 @@ func (r *Router) handleCreateJob(w http.ResponseWriter, req *http.Request) {
|
||||
return
|
||||
}
|
||||
if !r.permits(p, in.Model) {
|
||||
auth.WriteForbiddenModel(w, in.Model)
|
||||
r.writeForbidden(w, p, in.Model)
|
||||
return
|
||||
}
|
||||
if why := r.imageRouteRefusal(meshesPath, in.Model, body); why != "" {
|
||||
if why := r.imageRouteRefusal(path, in.Model, body); why != "" {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", why)
|
||||
return
|
||||
}
|
||||
if path == workflowsPath {
|
||||
if m, known := r.modelMap()[in.Model]; known {
|
||||
run, _ := parseRunRequest(m, body)
|
||||
if err := r.checkRunFiles(req.Context(), ownerOf(p), run); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
now := time.Now()
|
||||
j := store.Job{ID: newID("job_"), Owner: ownerOf(p), KeyID: p.KeyID, Endpoint: meshesPath, Model: in.Model,
|
||||
j := store.Job{ID: newID("job_"), Owner: ownerOf(p), KeyID: p.KeyID, Endpoint: path, Model: in.Model,
|
||||
Input: body, Status: store.JobQueued, CreatedAt: now, ExpiresAt: now.Add(jobWindow)}
|
||||
if err := r.store.PutJob(req.Context(), j); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "server_error", err.Error())
|
||||
|
||||
@@ -283,3 +283,80 @@ func TestJobsAreTheirOwners(t *testing.T) {
|
||||
t.Errorf("another project read a job's result: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAJobWaitingOnAnotherModelsCardsSaysSo(t *testing.T) {
|
||||
c := newJobComfy(t)
|
||||
rt, secret := jobRouter(t, c)
|
||||
rt.cfg.ActivationWait = 10 * time.Millisecond
|
||||
rt.SetActivator(&waitingActivator{fakeActivator: fakeActivator{onDemand: map[string]bool{"sleepy": true}},
|
||||
heldBy: "flux-schnell", eta: 90 * time.Second})
|
||||
j := queueJob(t, rt, "job_w", store.JobQueued, `{"model":"sleepy","prompt":"a cube"}`)
|
||||
rt.wakeJobs()
|
||||
|
||||
got := waitJob(t, rt.Handler(), secret, j.ID, func(j map[string]any) bool { return j["progress"] != nil })
|
||||
p, _ := got["progress"].(map[string]any)
|
||||
if got["status"] != "running" || p["state"] != "waiting_gpu" || p["held_by"] != "flux-schnell" || p["eta_seconds"] != 90.0 {
|
||||
t.Errorf("job %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
type countingUnservable struct {
|
||||
unservableActivator
|
||||
asked atomic.Int32
|
||||
}
|
||||
|
||||
func (c *countingUnservable) Unservable(model string) (string, bool) {
|
||||
c.asked.Add(1)
|
||||
return c.unservableActivator.Unservable(model)
|
||||
}
|
||||
|
||||
func TestAJobSaidToFitNowhereKeepsAskingAWhileBeforeFailing(t *testing.T) {
|
||||
// An agent restarting re-registers without its engines for a few
|
||||
// seconds; five 3D jobs asked for in those seconds failed for good.
|
||||
grace, retry := jobUnplacedGrace, jobUnplacedRetry
|
||||
jobUnplacedGrace, jobUnplacedRetry = 300*time.Millisecond, 20*time.Millisecond
|
||||
t.Cleanup(func() { jobUnplacedGrace, jobUnplacedRetry = grace, retry })
|
||||
|
||||
c := newJobComfy(t)
|
||||
rt, secret := jobRouter(t, c)
|
||||
act := &countingUnservable{unservableActivator: unservableActivator{
|
||||
fakeActivator: fakeActivator{onDemand: map[string]bool{"sleepy": true}},
|
||||
why: map[string]string{"sleepy": "sleepy cannot be placed on any node: needs 15 GB"},
|
||||
}}
|
||||
rt.SetActivator(act)
|
||||
j := queueJob(t, rt, "job_u", store.JobQueued, `{"model":"sleepy","prompt":"a cube"}`)
|
||||
start := time.Now()
|
||||
rt.wakeJobs()
|
||||
|
||||
got := waitJob(t, rt.Handler(), secret, j.ID, jobDone)
|
||||
if got["status"] != "failed" || !strings.Contains(fmt.Sprint(got["error"]), "needs 15 GB") {
|
||||
t.Errorf("job %v", got)
|
||||
}
|
||||
if took := time.Since(start); took < jobUnplacedGrace {
|
||||
t.Errorf("failed after %s, before the grace of %s", took, jobUnplacedGrace)
|
||||
}
|
||||
if n := act.asked.Load(); n < 3 {
|
||||
t.Errorf("asked %d times in the grace", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAStandbyLeavesJobsToTheLeader(t *testing.T) {
|
||||
// A standby's router has no endpoints; a 3D job it leased was told
|
||||
// "loading" for seven minutes while the leader served the jobs behind it.
|
||||
c := newJobComfy(t)
|
||||
rt, secret := jobRouter(t, c)
|
||||
var lead atomic.Bool
|
||||
rt.SetLeader(lead.Load)
|
||||
j := queueJob(t, rt, "job_s", store.JobQueued, `{"model":"flux","prompt":"a cube"}`)
|
||||
rt.wakeJobs()
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if got, _ := rt.store.JobByID(t.Context(), j.ID); got.Status != store.JobQueued {
|
||||
t.Fatalf("a standby took the job: %s", got.Status)
|
||||
}
|
||||
|
||||
lead.Store(true)
|
||||
rt.wakeJobs()
|
||||
if got := waitJob(t, rt.Handler(), secret, j.ID, jobDone); got["status"] != "completed" {
|
||||
t.Errorf("the leader did not serve it: %v", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package router
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -24,10 +25,20 @@ const (
|
||||
jobPoll = 5 * time.Second
|
||||
// jobCancelCheck is how often a running job looks for a cancel.
|
||||
jobCancelCheck = 2 * time.Second
|
||||
// jobMaxResult bounds what one job may answer: a mesh, not a film.
|
||||
// jobMaxResult bounds what one job may answer: a mesh, a short video.
|
||||
jobMaxResult = 256 << 20
|
||||
)
|
||||
|
||||
// jobUnplacedGrace is how long a job keeps asking for a model said to fit
|
||||
// nowhere before that is its end, and jobUnplacedRetry how often it asks.
|
||||
// "Fits nowhere" is said for a moment by a cluster changing under it — an
|
||||
// agent restarting re-registers without its engines for a few seconds — and
|
||||
// a job failed by that moment was failed by nothing wrong with it.
|
||||
var (
|
||||
jobUnplacedGrace = 5 * time.Minute
|
||||
jobUnplacedRetry = 15 * time.Second
|
||||
)
|
||||
|
||||
// wakeJobs has the worker look for work now rather than at its next poll.
|
||||
func (r *Router) wakeJobs() {
|
||||
select {
|
||||
@@ -42,7 +53,7 @@ func (r *Router) runJobs(ctx context.Context) {
|
||||
t := time.NewTicker(jobPoll)
|
||||
defer t.Stop()
|
||||
for {
|
||||
for {
|
||||
for r.leading() {
|
||||
now := time.Now()
|
||||
j, ok, err := r.store.ClaimJob(ctx, owner, now, now.Add(jobLease))
|
||||
if err != nil {
|
||||
@@ -151,6 +162,7 @@ func (r *Router) serveJob(ctx context.Context, j *store.Job) error {
|
||||
}()
|
||||
|
||||
handler := r.handleInference(j.Endpoint)
|
||||
var unplaced time.Time // when it was first said to fit nowhere
|
||||
for attempt := 1; ; attempt++ {
|
||||
hreq, err := http.NewRequestWithContext(auth.WithPrincipal(rctx, p), http.MethodPost, j.Endpoint, bytes.NewReader(j.Input))
|
||||
if err != nil {
|
||||
@@ -180,6 +192,17 @@ func (r *Router) serveJob(ctx context.Context, j *store.Job) error {
|
||||
// because waiting does not help.
|
||||
if wait := w.header.Get("Retry-After"); wait != "" && (status == http.StatusTooManyRequests || status >= 500) &&
|
||||
time.Now().Before(j.ExpiresAt) {
|
||||
if by := w.header.Get(waitingForHeader); by != "" {
|
||||
// Kept as its progress: whoever asks after it is told what
|
||||
// it waits on, rather than nothing at all.
|
||||
p := map[string]any{"state": "waiting_gpu", "held_by": by}
|
||||
if eta, err := strconv.Atoi(w.header.Get(etaHeader)); err == nil && eta > 0 {
|
||||
p["eta_seconds"] = eta
|
||||
}
|
||||
if b, err := json.Marshal(p); err == nil {
|
||||
_ = r.store.SetJobProgress(ctx, j.ID, string(b), "")
|
||||
}
|
||||
}
|
||||
secs, _ := strconv.Atoi(wait)
|
||||
select {
|
||||
case <-rctx.Done():
|
||||
@@ -188,6 +211,18 @@ func (r *Router) serveJob(ctx context.Context, j *store.Job) error {
|
||||
}
|
||||
continue
|
||||
}
|
||||
if status == http.StatusServiceUnavailable && time.Now().Before(j.ExpiresAt) {
|
||||
if unplaced.IsZero() {
|
||||
unplaced = time.Now()
|
||||
}
|
||||
if time.Since(unplaced) < jobUnplacedGrace {
|
||||
select {
|
||||
case <-rctx.Done():
|
||||
case <-time.After(jobUnplacedRetry):
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
if status >= 300 {
|
||||
return fail(errorMessage(w.buf.Bytes()))
|
||||
}
|
||||
@@ -229,6 +264,22 @@ func extensionFor(contentType string) string {
|
||||
return ".jpg"
|
||||
case "image/webp":
|
||||
return ".webp"
|
||||
case "image/gif":
|
||||
return ".gif"
|
||||
case "video/mp4":
|
||||
return ".mp4"
|
||||
case "video/webm":
|
||||
return ".webm"
|
||||
case "video/quicktime":
|
||||
return ".mov"
|
||||
case "audio/wav", "audio/wave", "audio/x-wav":
|
||||
return ".wav"
|
||||
case "audio/mpeg":
|
||||
return ".mp3"
|
||||
case "audio/flac":
|
||||
return ".flac"
|
||||
case "audio/ogg":
|
||||
return ".ogg"
|
||||
}
|
||||
return ".bin"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/web"
|
||||
)
|
||||
|
||||
// What a person may say not to use, besides a licence by its id: every
|
||||
// licence that forbids commercial use, and every licence that grants nothing
|
||||
// in a region. Rules rather than a list, so a model added later under such
|
||||
// a licence is left out without anybody remembering to tick it.
|
||||
const (
|
||||
licenceUnspecified = "unspecified"
|
||||
ruleNonCommercial = "rule:non-commercial"
|
||||
ruleRegionPrefix = "rule:region:"
|
||||
)
|
||||
|
||||
// licenceKey is how a model's licence is matched: its id, else its name,
|
||||
// lowercased; licenceUnspecified for a model whose entry names none.
|
||||
func licenceKey(m catalog.Model) string {
|
||||
if m.License == nil {
|
||||
return licenceUnspecified
|
||||
}
|
||||
if m.License.ID != "" {
|
||||
return strings.ToLower(m.License.ID)
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(m.License.Name))
|
||||
}
|
||||
|
||||
// licenceExcluded says which of the person's choices leaves a model out,
|
||||
// empty when none does.
|
||||
func licenceExcluded(excluded []string, m catalog.Model) string {
|
||||
if len(excluded) == 0 {
|
||||
return ""
|
||||
}
|
||||
key := licenceKey(m)
|
||||
// Its name too: a choice made before the entry gave an id keeps
|
||||
// holding once it does.
|
||||
name := key
|
||||
if m.License != nil {
|
||||
name = strings.ToLower(strings.TrimSpace(m.License.Name))
|
||||
}
|
||||
for _, e := range excluded {
|
||||
switch {
|
||||
case strings.EqualFold(e, key), strings.EqualFold(e, name):
|
||||
return e
|
||||
case e == ruleNonCommercial:
|
||||
if m.License != nil && m.License.Commercial != nil && !*m.License.Commercial {
|
||||
return e
|
||||
}
|
||||
case strings.HasPrefix(e, ruleRegionPrefix):
|
||||
if m.License != nil && slices.Contains(m.License.ExcludedRegions, strings.TrimPrefix(e, ruleRegionPrefix)) {
|
||||
return e
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// excludedByLicence says why the principal's person has left this model
|
||||
// out, empty when they have not.
|
||||
func (r *Router) excludedByLicence(p auth.Principal, requested string) string {
|
||||
if len(p.Limits.ExcludedLicences) == 0 {
|
||||
return ""
|
||||
}
|
||||
_, m := r.resolve(requested)
|
||||
return licenceExcluded(p.Limits.ExcludedLicences, m)
|
||||
}
|
||||
|
||||
// licenceChoices are what the profile page offers: each licence the
|
||||
// catalog's models are under, with the models, and the regions any of them
|
||||
// excludes.
|
||||
func (r *Router) licenceChoices() web.LicenceChoices {
|
||||
by := map[string]*web.LicenceChoice{}
|
||||
regions := map[string]bool{}
|
||||
nonCommercial := false
|
||||
for name, m := range r.modelMap() {
|
||||
if name != m.Name || r.hiddenModel(name) {
|
||||
continue // an alias, or a guardrail's judge nobody asks for
|
||||
}
|
||||
key := licenceKey(m)
|
||||
c, ok := by[key]
|
||||
if !ok {
|
||||
c = &web.LicenceChoice{Key: key}
|
||||
if m.License != nil {
|
||||
c.Name, c.URL = m.License.Name, m.License.URL
|
||||
if cm := m.License.Commercial; cm != nil {
|
||||
c.Commercial = map[bool]string{true: "yes", false: "no"}[*cm]
|
||||
}
|
||||
c.ExcludedRegions = m.License.ExcludedRegions
|
||||
}
|
||||
by[key] = c
|
||||
}
|
||||
c.Models = append(c.Models, m.Name)
|
||||
if m.License != nil {
|
||||
for _, reg := range m.License.ExcludedRegions {
|
||||
regions[reg] = true
|
||||
}
|
||||
if m.License.Commercial != nil && !*m.License.Commercial {
|
||||
nonCommercial = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var out web.LicenceChoices
|
||||
for _, c := range by {
|
||||
sort.Strings(c.Models)
|
||||
out.Licences = append(out.Licences, *c)
|
||||
}
|
||||
sort.Slice(out.Licences, func(i, j int) bool { return out.Licences[i].Key < out.Licences[j].Key })
|
||||
for reg := range regions {
|
||||
out.Regions = append(out.Regions, reg)
|
||||
}
|
||||
sort.Strings(out.Regions)
|
||||
out.AnyNonCommercial = nonCommercial
|
||||
return out
|
||||
}
|
||||
|
||||
// writeForbidden refuses a model the principal may not use, saying why when
|
||||
// it is the person's own choice of licences rather than the key's allowlist.
|
||||
func (r *Router) writeForbidden(w http.ResponseWriter, p auth.Principal, model string) {
|
||||
if why := r.excludedByLicence(p, model); why != "" {
|
||||
writeErrorCode(w, http.StatusForbidden, "invalid_request_error", "licence_excluded",
|
||||
fmt.Sprintf("the model %q is under a licence its account has chosen not to use (%s); change it on the profile page", model, why))
|
||||
return
|
||||
}
|
||||
auth.WriteForbiddenModel(w, model)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
func TestAPersonsLicenceChoiceLeavesModelsOutOfTheirKeys(t *testing.T) {
|
||||
// A game sold in the EU cannot ship what a licence excluding the EU
|
||||
// made: its maker says so once, and no key of theirs reaches such a model.
|
||||
yes, no := true, false
|
||||
names := []string{"mit", "nc", "eu-out", "plain"}
|
||||
e := newFakeEngine(t, "m")
|
||||
cfg := baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: names})
|
||||
mk := func(name string, l *catalog.License) catalog.Model {
|
||||
m := catModel(name, nil)
|
||||
m.License = l
|
||||
return m
|
||||
}
|
||||
cfg.Models = []catalog.Model{
|
||||
mk("mit", &catalog.License{ID: "MIT", Name: "MIT", Commercial: &yes}),
|
||||
mk("nc", &catalog.License{ID: "CC-BY-NC-4.0", Name: "CC BY-NC 4.0", Commercial: &no}),
|
||||
mk("eu-out", &catalog.License{ID: "tencent-hunyuan-community", Name: "Tencent Hunyuan Community",
|
||||
ExcludedRegions: []string{"EU", "UK", "KR"}}),
|
||||
mk("plain", nil),
|
||||
}
|
||||
cfg.Auth.Enabled = true
|
||||
cfg.Auth.Store = filepath.Join(t.TempDir(), "store.db")
|
||||
rt := newTestRouter(t, cfg)
|
||||
ctx := t.Context()
|
||||
if err := rt.store.CreateUser(ctx, store.User{ID: "usr-g", Email: "g@game.test", State: store.UserActive,
|
||||
CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := store.KeyPrefix + strings.Repeat("g", 43)
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
if err := rt.store.CreateUserKey(ctx, "usr-g", store.Key{ID: "key-g", Hash: sum[:], Prefix: "gggg",
|
||||
Name: "game", CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rt.store.SetUserExcludedLicences(ctx, "usr-g",
|
||||
[]string{"rule:non-commercial", "rule:region:EU", "unspecified"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := rt.Handler()
|
||||
|
||||
_, list, raw := call(t, h, secret, http.MethodGet, "/v1/models", nil)
|
||||
var listed []string
|
||||
for _, m := range list["data"].([]any) {
|
||||
listed = append(listed, m.(map[string]any)["id"].(string))
|
||||
}
|
||||
if !slices.Equal(listed, []string{"mit"}) {
|
||||
t.Errorf("listed %v: %s", listed, raw)
|
||||
}
|
||||
for _, name := range []string{"nc", "eu-out", "plain"} {
|
||||
code, _, raw := call(t, h, secret, http.MethodPost, "/v1/chat/completions",
|
||||
map[string]any{"model": name, "messages": []any{map[string]any{"role": "user", "content": "hi"}}})
|
||||
if code != http.StatusForbidden || !strings.Contains(raw, "licence_excluded") {
|
||||
t.Errorf("%s: %d %s", name, code, raw)
|
||||
}
|
||||
}
|
||||
if code, _, raw := call(t, h, secret, http.MethodPost, "/v1/chat/completions",
|
||||
map[string]any{"model": "mit", "messages": []any{map[string]any{"role": "user", "content": "hi"}}}); code != 200 {
|
||||
t.Errorf("mit: %d %s", code, raw)
|
||||
}
|
||||
|
||||
// A choice made by a licence's name holds once its entry gives an id.
|
||||
if why := licenceExcluded([]string{"cc by-nc 4.0"}, cfg.Models[1]); why == "" {
|
||||
t.Error("a choice by name did not hold against an entry with an id")
|
||||
}
|
||||
|
||||
// The profile page is offered every licence, and the regions in play.
|
||||
c := rt.licenceChoices()
|
||||
var keys []string
|
||||
for _, l := range c.Licences {
|
||||
keys = append(keys, l.Key)
|
||||
}
|
||||
if !slices.Equal(keys, []string{"cc-by-nc-4.0", "mit", "tencent-hunyuan-community", "unspecified"}) ||
|
||||
!slices.Equal(c.Regions, []string{"EU", "KR", "UK"}) || !c.AnyNonCommercial {
|
||||
t.Errorf("choices %+v", c)
|
||||
}
|
||||
}
|
||||
@@ -361,3 +361,43 @@ func TestThe3DPageMakesAJobAndShowsItsMeshToItsOwnerOnly(t *testing.T) {
|
||||
t.Errorf("another project's person read the job: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAModelAsleepUntilAskedIsListedAndSaidAsleep(t *testing.T) {
|
||||
// triposr and flux-schnell load when asked and stop when idle; a client
|
||||
// asking /v1/models which model makes 3D models found neither while
|
||||
// they slept, and so never asked.
|
||||
cfg := baseConfig()
|
||||
asleep := catModel("triposr", nil)
|
||||
asleep.Scaling = catalog.Scaling{Min: 0, Max: 1}
|
||||
asleep.ComfyUI = &catalog.ComfyUI{Output: catalog.OutputMeshes, Workflow: "w.json"}
|
||||
asleep.Profiles = []catalog.Profile{{Name: "gpu", Engine: catalog.EngineComfyUI, GPUClass: "6000-24g", TP: 1}}
|
||||
cfg.Models = []catalog.Model{asleep, catModel("chat", nil)}
|
||||
rt := newTestRouter(t, cfg)
|
||||
e := newFakeEngine(t, "chat")
|
||||
rt.SetEndpoints(t.Context(), []endpoint.Info{{ID: "c", URL: e.srv.URL, Models: []string{"chat"}}})
|
||||
rec := httptest.NewRecorder()
|
||||
rt.Handler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/v1/models", nil))
|
||||
var list struct {
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
Loaded bool `json:"loaded"`
|
||||
Capabilities map[string]bool `json:"capabilities"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &list); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := map[string]bool{}
|
||||
for _, m := range list.Data {
|
||||
got[m.ID] = m.Loaded
|
||||
if m.ID == "triposr" && !m.Capabilities["meshes"] {
|
||||
t.Error("triposr is not said to make meshes")
|
||||
}
|
||||
}
|
||||
if loaded, ok := got["triposr"]; !ok || loaded {
|
||||
t.Errorf("triposr listed %v, loaded %v: wanted listed and asleep", ok, loaded)
|
||||
}
|
||||
if !got["chat"] {
|
||||
t.Error("a model serving is not said loaded")
|
||||
}
|
||||
}
|
||||
@@ -40,3 +40,27 @@ func TestTheEditorSetupScriptNeedsNoKey(t *testing.T) {
|
||||
t.Errorf("%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheAPIIsDescribedToWhoeverAsksAndTheAdminAPIToStaffOnly(t *testing.T) {
|
||||
rt, _ := chatRouter(t, "http://127.0.0.1:1")
|
||||
h := rt.Handler()
|
||||
get := func(path string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
return w
|
||||
}
|
||||
// Asked for before there is a key, by SDK generators among others.
|
||||
if w := get("/v1/openapi.json"); w.Code != http.StatusOK || !strings.Contains(w.Body.String(), `"title": "llmgw client API"`) {
|
||||
t.Errorf("/v1/openapi.json: %d %.80s", w.Code, w.Body.String())
|
||||
}
|
||||
w := get("/docs/api")
|
||||
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "/v1/openapi.json") || !strings.Contains(w.Body.String(), "swagger-ui-bundle.js") {
|
||||
t.Fatalf("/docs/api: %d", w.Code)
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "/docs/api/admin.json") {
|
||||
t.Error("the admin API's document is offered to somebody not signed in")
|
||||
}
|
||||
if w := get("/docs/api/admin.json"); w.Code == http.StatusOK {
|
||||
t.Errorf("the admin API's document without a session: %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -224,7 +224,7 @@ func (r *Router) retrieve(ctx context.Context, w http.ResponseWriter, req *parse
|
||||
return fail(&kbError{status: http.StatusBadRequest, kind: "invalid_request_error", msg: err.Error()})
|
||||
}
|
||||
req.UpstreamBody = body
|
||||
req.PromptTokens = len(body) / 4
|
||||
req.PromptTokens = promptBytesOf(body) / 4
|
||||
req.knowledge = true
|
||||
// The collections searched, which is who else could have had these
|
||||
// passages added: the prefix cache is shared among exactly them.
|
||||
|
||||
@@ -682,6 +682,19 @@ func (r *Router) Stop() {
|
||||
// SetLeader tells /leader how to know whether this process leads.
|
||||
func (r *Router) SetLeader(f func() bool) { r.isLeader.Store(&f) }
|
||||
|
||||
// leading says whether this process leads, which a process with no rival
|
||||
// always does.
|
||||
//
|
||||
// Jobs and batches are served only where it does. A standby's router has no
|
||||
// endpoints — its reconciler placed nothing — and a client's request reaches
|
||||
// the leader by being forwarded at the door, which a job replayed inside the
|
||||
// process never passes: a 3D job leased by the standby was told "loading"
|
||||
// for as long as anyone let it, while the leader served the jobs behind it.
|
||||
func (r *Router) leading() bool {
|
||||
f := r.isLeader.Load()
|
||||
return f == nil || (*f)()
|
||||
}
|
||||
|
||||
// Registry exposes the endpoint registry, for tests and admin endpoints.
|
||||
func (r *Router) Registry() *endpoint.Registry { return r.reg }
|
||||
|
||||
@@ -1023,8 +1036,7 @@ func (r *Router) dispatchOne(ctx context.Context, w http.ResponseWriter, req *pa
|
||||
// Asked for, still loading. Saying "no endpoint serves this" would
|
||||
// be a lie, and a client told to retry will succeed where one told
|
||||
// the model does not exist gives up.
|
||||
return dispatchResult{err: fmt.Errorf("%w: %s", errLoading,
|
||||
"the model is loading; retry shortly")}
|
||||
return dispatchResult{err: r.loadingErr(model)}
|
||||
}
|
||||
return dispatchResult{err: fmt.Errorf("%w: %s", errNoCapacity, noEndpointFor(model, slices.DeleteFunc(r.servedModels(), r.hiddenModel)))}
|
||||
}
|
||||
|
||||
@@ -808,3 +808,36 @@ func TestABatchOfEmbeddingsIsCheckedTextByText(t *testing.T) {
|
||||
t.Errorf("a passage over the context was let through: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
type waitingActivator struct {
|
||||
fakeActivator
|
||||
heldBy string
|
||||
eta time.Duration
|
||||
}
|
||||
|
||||
func (a *waitingActivator) WaitFor(string) (string, time.Duration, bool) {
|
||||
return a.heldBy, a.eta, true
|
||||
}
|
||||
|
||||
func TestAModelWaitingOnAnothersCardsSaysWhoAndForHowLong(t *testing.T) {
|
||||
// A 3D job waiting for the picture model to give up the one card was
|
||||
// told nothing at all; its client could not tell waiting from stuck.
|
||||
e := newFakeEngine(t, "m")
|
||||
cfg := baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: []string{"m"}})
|
||||
cfg.Router.ActivationWait = 50 * time.Millisecond
|
||||
rt := newTestRouter(t, cfg)
|
||||
rt.SetActivator(&waitingActivator{fakeActivator: fakeActivator{onDemand: map[string]bool{"sleepy": true}},
|
||||
heldBy: "flux-schnell", eta: 12 * time.Second})
|
||||
|
||||
rec := post(t, rt.Handler(), map[string]any{"model": "sleepy", "messages": []any{}}, nil)
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("status %d, expected 503", rec.Code)
|
||||
}
|
||||
if rec.Header().Get(waitingForHeader) != "flux-schnell" || rec.Header().Get(etaHeader) != "12" ||
|
||||
rec.Header().Get("Retry-After") != "12" {
|
||||
t.Errorf("headers %v", rec.Header())
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "waiting for flux-schnell") {
|
||||
t.Errorf("body %s", rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,14 @@ import (
|
||||
// project: OpenAI's metadata (at most 16 keys, as OpenAI allows) and the
|
||||
// x-llmgw-tags header, which wins for a key both give.
|
||||
const (
|
||||
tagsHeader = "x-llmgw-tags"
|
||||
maxTags = 16
|
||||
maxTagValue = 512
|
||||
maxEndUser = 256
|
||||
tagsHeader = "x-llmgw-tags"
|
||||
// waitingForHeader and etaHeader say, on a 503, which model holds the
|
||||
// cards the one asked for waits on, and about how many seconds it waits.
|
||||
waitingForHeader = "X-Llmgw-Waiting-For"
|
||||
etaHeader = "X-Llmgw-Eta-Seconds"
|
||||
maxTags = 16
|
||||
maxTagValue = 512
|
||||
maxEndUser = 256
|
||||
)
|
||||
|
||||
var tagKey = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,64}$`)
|
||||
|
||||
@@ -293,7 +293,7 @@ func (r *Router) loadThread(ctx context.Context, req *parsedRequest) (int, error
|
||||
return 0, err
|
||||
}
|
||||
req.UpstreamBody = body
|
||||
req.PromptTokens = len(body) / 4
|
||||
req.PromptTokens = promptBytesOf(body) / 4
|
||||
return dropped, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/crc32"
|
||||
"math"
|
||||
"net/http"
|
||||
"path"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/comfy"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/router/proxy"
|
||||
)
|
||||
|
||||
// Workflow runs: whatever a ComfyUI model makes, from the inputs its catalog
|
||||
// entry declares. Pictures and meshes have fixed shapes of their own
|
||||
// (images.go, meshes.go); this is the one shape for everything, so a model
|
||||
// is added to the catalog as data — its workflow, its inputs, its output
|
||||
// kind — rather than as code. A run is a job like a 3D model (jobs.go): it
|
||||
// is queued, asked after, and its result is a file.
|
||||
|
||||
// workflowsPath is where workflow runs are asked for. The synchronous path
|
||||
// behind it is served to the job worker only; clients get a job.
|
||||
const workflowsPath = "/v1/workflows/runs"
|
||||
|
||||
// runRequest is a request to run a model's workflow.
|
||||
type runRequest struct {
|
||||
Model string `json:"model"`
|
||||
Inputs map[string]json.RawMessage `json:"inputs"`
|
||||
Seed *int64 `json:"seed"`
|
||||
|
||||
// values are the inputs that are not files, as the workflow gets them.
|
||||
values map[string]any
|
||||
// files are the file inputs, by input name.
|
||||
files map[string]runFile
|
||||
}
|
||||
|
||||
// runFile is a file input: its bytes, or the id of a file in /v1/files to be
|
||||
// read when the run is served.
|
||||
type runFile struct {
|
||||
data []byte
|
||||
fileID string
|
||||
ext string
|
||||
}
|
||||
|
||||
// maxTextInput bounds a text input that names no max of its own.
|
||||
const maxTextInput = 32 << 10
|
||||
|
||||
// parseRunRequest checks a request against what the model's workflow takes.
|
||||
// It refuses an input the workflow does not take, a required one left out, a
|
||||
// value of the wrong type or out of its bounds, and a file that is not what
|
||||
// the input says.
|
||||
func parseRunRequest(m catalog.Model, body []byte) (runRequest, error) {
|
||||
var in runRequest
|
||||
if err := json.Unmarshal(body, &in); err != nil {
|
||||
return in, err
|
||||
}
|
||||
in.values, in.files = map[string]any{}, map[string]runFile{}
|
||||
declared := m.ComfyInputs()
|
||||
for name := range in.Inputs {
|
||||
if !slices.ContainsFunc(declared, func(d catalog.Input) bool { return d.Name == name }) {
|
||||
names := make([]string, 0, len(declared))
|
||||
for _, d := range declared {
|
||||
names = append(names, d.Name)
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return in, fmt.Errorf("%s takes no inputs, and was given %q", m.Name, name)
|
||||
}
|
||||
return in, fmt.Errorf("%s takes no input %q (it takes %s)", m.Name, name, strings.Join(names, ", "))
|
||||
}
|
||||
}
|
||||
for _, d := range declared {
|
||||
raw, given := in.Inputs[d.Name]
|
||||
if !given || string(raw) == "null" {
|
||||
if d.Required {
|
||||
return in, fmt.Errorf("input %q is required: %s", d.Name, describeInput(d))
|
||||
}
|
||||
if d.Default != nil {
|
||||
in.values[d.Name] = d.Default
|
||||
}
|
||||
continue
|
||||
}
|
||||
if d.IsFile() {
|
||||
f, err := parseFileInput(d, raw)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
in.files[d.Name] = f
|
||||
continue
|
||||
}
|
||||
v, err := parseValueInput(d, raw)
|
||||
if err != nil {
|
||||
return in, err
|
||||
}
|
||||
in.values[d.Name] = v
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
func describeInput(d catalog.Input) string {
|
||||
s := d.Type
|
||||
if d.Type == catalog.InputChoice {
|
||||
s += " of " + strings.Join(d.Choices, ", ")
|
||||
}
|
||||
if d.Doc != "" {
|
||||
s += " — " + d.Doc
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseValueInput(d catalog.Input, raw json.RawMessage) (any, error) {
|
||||
bounds := func(f float64) error {
|
||||
if d.Min != nil && f < *d.Min || d.Max != nil && f > *d.Max {
|
||||
return fmt.Errorf("input %q is %v, outside %s", d.Name, f, boundsText(d))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch d.Type {
|
||||
case catalog.InputText:
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) != nil {
|
||||
return nil, fmt.Errorf("input %q is text", d.Name)
|
||||
}
|
||||
limit := maxTextInput
|
||||
if d.Max != nil {
|
||||
limit = int(*d.Max)
|
||||
}
|
||||
if n := utf8.RuneCountInString(s); n > limit {
|
||||
return nil, fmt.Errorf("input %q is %d characters, past %d", d.Name, n, limit)
|
||||
}
|
||||
return s, nil
|
||||
case catalog.InputInt:
|
||||
var f float64
|
||||
if json.Unmarshal(raw, &f) != nil || f != math.Trunc(f) || math.Abs(f) > 1<<53 {
|
||||
return nil, fmt.Errorf("input %q is a whole number", d.Name)
|
||||
}
|
||||
return int64(f), bounds(f)
|
||||
case catalog.InputFloat:
|
||||
var f float64
|
||||
if json.Unmarshal(raw, &f) != nil || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||
return nil, fmt.Errorf("input %q is a number", d.Name)
|
||||
}
|
||||
return f, bounds(f)
|
||||
case catalog.InputBool:
|
||||
var b bool
|
||||
if json.Unmarshal(raw, &b) != nil {
|
||||
return nil, fmt.Errorf("input %q is true or false", d.Name)
|
||||
}
|
||||
return b, nil
|
||||
case catalog.InputChoice:
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) != nil || !slices.Contains(d.Choices, s) {
|
||||
return nil, fmt.Errorf("input %q is one of %s", d.Name, strings.Join(d.Choices, ", "))
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
return nil, fmt.Errorf("input %q has type %q, which takes no value", d.Name, d.Type)
|
||||
}
|
||||
|
||||
func boundsText(d catalog.Input) string {
|
||||
switch {
|
||||
case d.Min != nil && d.Max != nil:
|
||||
return fmt.Sprintf("%v to %v", *d.Min, *d.Max)
|
||||
case d.Min != nil:
|
||||
return fmt.Sprintf("at least %v", *d.Min)
|
||||
case d.Max != nil:
|
||||
return fmt.Sprintf("at most %v", *d.Max)
|
||||
}
|
||||
return "its bounds"
|
||||
}
|
||||
|
||||
// parseFileInput reads a file input: base64, a data URL, or a file id.
|
||||
func parseFileInput(d catalog.Input, raw json.RawMessage) (runFile, error) {
|
||||
var s string
|
||||
if json.Unmarshal(raw, &s) != nil || strings.TrimSpace(s) == "" {
|
||||
return runFile{}, fmt.Errorf("input %q is a file: base64, a data URL, or the id of a file uploaded to /v1/files", d.Name)
|
||||
}
|
||||
s = strings.TrimSpace(s)
|
||||
if strings.HasPrefix(s, "file-") && len(s) < 80 && !strings.ContainsAny(s, " /") {
|
||||
return runFile{fileID: s}, nil
|
||||
}
|
||||
if strings.HasPrefix(s, "data:") {
|
||||
_, after, ok := strings.Cut(s, ";base64,")
|
||||
if !ok {
|
||||
return runFile{}, fmt.Errorf("input %q is a data URL without base64 in it", d.Name)
|
||||
}
|
||||
s = after
|
||||
}
|
||||
if int64(base64.StdEncoding.DecodedLen(len(s))) > d.MaxBytes()+3 {
|
||||
return runFile{}, fmt.Errorf("input %q is past %d MB: upload it to /v1/files and give its id", d.Name, d.MaxBytes()>>20)
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return runFile{}, fmt.Errorf("input %q is not base64: %v", d.Name, err)
|
||||
}
|
||||
return checkFileInput(d, b)
|
||||
}
|
||||
|
||||
// checkFileInput is a file input's bytes, if they are what the input says.
|
||||
func checkFileInput(d catalog.Input, b []byte) (runFile, error) {
|
||||
if int64(len(b)) > d.MaxBytes() {
|
||||
return runFile{}, fmt.Errorf("input %q is past %d MB", d.Name, d.MaxBytes()>>20)
|
||||
}
|
||||
ext, kind := sniffFile(b)
|
||||
want := map[string]string{catalog.InputImage: "image", catalog.InputMask: "image",
|
||||
catalog.InputAudio: "audio", catalog.InputVideo: "video"}[d.Type]
|
||||
if want != "" && kind != want {
|
||||
return runFile{}, fmt.Errorf("input %q is %s, and this is not one it can read", d.Name, map[string]string{
|
||||
"image": "a PNG, JPEG, WebP or GIF picture", "audio": "WAV, MP3, FLAC or Ogg audio",
|
||||
"video": "an MP4, WebM or QuickTime video"}[want])
|
||||
}
|
||||
if ext == "" {
|
||||
ext = ".bin"
|
||||
}
|
||||
return runFile{data: b, ext: ext}, nil
|
||||
}
|
||||
|
||||
// sniffFile is a file's extension and kind (image, audio, video) by its
|
||||
// first bytes, or empty when it is none of the kinds a workflow loads.
|
||||
func sniffFile(b []byte) (ext, kind string) {
|
||||
has := func(off int, sig string) bool { return len(b) >= off+len(sig) && string(b[off:off+len(sig)]) == sig }
|
||||
switch {
|
||||
case has(0, "\x89PNG\r\n\x1a\n"):
|
||||
return ".png", "image"
|
||||
case has(0, "\xff\xd8\xff"):
|
||||
return ".jpg", "image"
|
||||
case has(0, "RIFF") && has(8, "WEBP"):
|
||||
return ".webp", "image"
|
||||
case has(0, "GIF87a"), has(0, "GIF89a"):
|
||||
return ".gif", "image"
|
||||
case has(0, "RIFF") && has(8, "WAVE"):
|
||||
return ".wav", "audio"
|
||||
case has(0, "fLaC"):
|
||||
return ".flac", "audio"
|
||||
case has(0, "OggS"):
|
||||
return ".ogg", "audio"
|
||||
case has(0, "ID3"), len(b) > 2 && b[0] == 0xff && b[1]&0xe0 == 0xe0:
|
||||
return ".mp3", "audio"
|
||||
case has(4, "ftypqt"):
|
||||
return ".mov", "video"
|
||||
case has(4, "ftyp"):
|
||||
return ".mp4", "video"
|
||||
case has(0, "\x1a\x45\xdf\xa3"):
|
||||
return ".webm", "video"
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// runFileIDs are the file ids a run's inputs name.
|
||||
func (in runRequest) runFileIDs() []string {
|
||||
var ids []string
|
||||
for _, f := range in.files {
|
||||
if f.fileID != "" {
|
||||
ids = append(ids, f.fileID)
|
||||
}
|
||||
}
|
||||
sort.Strings(ids)
|
||||
return ids
|
||||
}
|
||||
|
||||
// checkRunFiles refuses a run naming a file its owner does not have.
|
||||
func (r *Router) checkRunFiles(ctx context.Context, owner string, in runRequest) error {
|
||||
for _, id := range in.runFileIDs() {
|
||||
f, err := r.store.FileByID(ctx, id)
|
||||
if err != nil || f.Owner != owner {
|
||||
return fmt.Errorf("no file %q", id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateRun answers a workflow run at a ComfyUI replica: the file inputs
|
||||
// put in its input directory, the workflow filled in and run, and the file
|
||||
// it made written back with its type — what the job keeps.
|
||||
func (r *Router) generateRun(ctx context.Context, w http.ResponseWriter, req *parsedRequest, ep endpoint.Info, body []byte) (proxy.Outcome, error) {
|
||||
m, known := r.modelMap()[req.Model]
|
||||
if !known {
|
||||
return proxy.Outcome{}, fmt.Errorf("%s: no catalog entry for %q says what its workflow takes", ep.ID, req.Model)
|
||||
}
|
||||
in, err := parseRunRequest(m, body)
|
||||
if err != nil {
|
||||
return proxy.Outcome{}, err
|
||||
}
|
||||
tpl, err := comfy.Load(ep.Workflow)
|
||||
if err != nil {
|
||||
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
|
||||
}
|
||||
if err := checkPlaceholders(m, tpl); err != nil {
|
||||
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
|
||||
}
|
||||
if t := r.cfg.Timeouts.Total; t > 0 {
|
||||
var cancel context.CancelFunc
|
||||
ctx, cancel = context.WithTimeout(ctx, t)
|
||||
defer cancel()
|
||||
}
|
||||
start := time.Now()
|
||||
fail := func(err error) (proxy.Outcome, error) {
|
||||
var ce *comfy.Error
|
||||
switch {
|
||||
case errors.As(err, &ce):
|
||||
return proxy.Outcome{Retryable: ce.Retryable}, err
|
||||
case errors.Is(err, context.Canceled) && ctxCauseIsClient(ctx):
|
||||
return proxy.Outcome{}, proxy.ErrClientGone
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
return proxy.Outcome{}, fmt.Errorf("%s finished nothing within %s", ep.ID, r.cfg.Timeouts.Total)
|
||||
}
|
||||
return proxy.Outcome{}, err
|
||||
}
|
||||
var upstream struct {
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &upstream)
|
||||
p := comfy.Params{Checkpoint: upstream.Model, Seed: comfy.RandomSeed(), Extra: in.values}
|
||||
if in.Seed != nil {
|
||||
p.Seed = *in.Seed
|
||||
}
|
||||
if s, ok := in.values["seed"].(int64); ok {
|
||||
p.Seed = s
|
||||
}
|
||||
// Each file under a name of its own: two runs' files must not overwrite
|
||||
// each other in the replica's input directory.
|
||||
names := make([]string, 0, len(in.files))
|
||||
for name := range in.files {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
f := in.files[name]
|
||||
if f.fileID != "" {
|
||||
pr, _ := auth.FromContext(ctx)
|
||||
d := m.ComfyInputs()[slices.IndexFunc(m.ComfyInputs(), func(x catalog.Input) bool { return x.Name == name })]
|
||||
meta, err := r.store.FileByID(ctx, f.fileID)
|
||||
if err != nil || meta.Owner != ownerOf(pr) {
|
||||
return proxy.Outcome{}, fmt.Errorf("input %q: no file %q", name, f.fileID)
|
||||
}
|
||||
b, err := r.store.FileContent(ctx, f.fileID)
|
||||
if err != nil {
|
||||
return proxy.Outcome{}, fmt.Errorf("input %q: reading file %q: %w", name, f.fileID, err)
|
||||
}
|
||||
if f, err = checkFileInput(d, b); err != nil {
|
||||
return proxy.Outcome{}, err
|
||||
}
|
||||
in.files[name] = f
|
||||
}
|
||||
rnd := make([]byte, 8)
|
||||
_, _ = rand.Read(rnd)
|
||||
f = in.files[name]
|
||||
up, err := r.comfy.Upload(ctx, ep.URL, ep.APIKey, "llmgw-"+hex.EncodeToString(rnd)+f.ext, f.data)
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
p.Extra[name] = up
|
||||
}
|
||||
workflow, err := tpl.Fill(p)
|
||||
if err != nil {
|
||||
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
|
||||
}
|
||||
files, err := r.comfy.GenerateWatch(ctx, ep.URL, ep.APIKey, workflow, r.jobProgress(ctx, req))
|
||||
if err != nil {
|
||||
return fail(err)
|
||||
}
|
||||
out, err := pickOutput(m.ComfyOutput(), files)
|
||||
if err != nil {
|
||||
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
|
||||
}
|
||||
data, ct := out.Data, out.ContentType
|
||||
if ct == "model/obj" {
|
||||
if data, err = objToGLB(data); err != nil {
|
||||
return proxy.Outcome{}, fmt.Errorf("%s: the mesh it saved: %w", ep.ID, err)
|
||||
}
|
||||
ct = "model/gltf-binary"
|
||||
}
|
||||
if m.License != nil {
|
||||
if data, err = withLicense(data, ct, *m.License); err != nil {
|
||||
// Refused rather than sent without it: the file travels without
|
||||
// the API, and the notice is what the terms ask to travel too.
|
||||
return proxy.Outcome{}, fmt.Errorf("%s: the licence could not be written into the file: %w", ep.ID, err)
|
||||
}
|
||||
w.Header().Set("x-llmgw-license", m.License.Name)
|
||||
}
|
||||
if _, ok := tpl.Placeholders()["seed"]; ok {
|
||||
w.Header().Set("x-llmgw-seed", strconv.FormatInt(p.Seed, 10))
|
||||
}
|
||||
w.Header().Set("Content-Type", ct)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write(data)
|
||||
took := time.Since(start)
|
||||
o := proxy.Outcome{Wrote: true, Status: http.StatusOK, Bytes: int64(len(data)), TTFT: took, Decode: took}
|
||||
switch m.ComfyOutput() {
|
||||
case catalog.OutputPictures:
|
||||
o.Images = 1
|
||||
case catalog.OutputMeshes:
|
||||
o.Meshes = 1
|
||||
}
|
||||
return o, nil
|
||||
}
|
||||
|
||||
// checkPlaceholders refuses a workflow that wants what no request can give:
|
||||
// a placeholder that is neither an input, nor one llmgw fills itself, nor
|
||||
// has a default of its own. The catalog's to fix, said as such.
|
||||
func checkPlaceholders(m catalog.Model, tpl *comfy.Template) error {
|
||||
var missing []string
|
||||
for name, hasDefault := range tpl.Placeholders() {
|
||||
if hasDefault || slices.Contains(comfy.Builtin, name) ||
|
||||
slices.ContainsFunc(m.ComfyInputs(), func(d catalog.Input) bool { return d.Name == name }) {
|
||||
continue
|
||||
}
|
||||
missing = append(missing, name)
|
||||
}
|
||||
if len(missing) > 0 {
|
||||
sort.Strings(missing)
|
||||
return fmt.Errorf("the workflow has placeholders %s that the catalog declares no input for and that have no default",
|
||||
strings.Join(missing, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// pickOutput is the file a run answers with: the first the workflow saved of
|
||||
// the model's output kind, or the first of any kind for files.
|
||||
func pickOutput(kind string, files []comfy.Image) (comfy.Image, error) {
|
||||
is := func(ct string) bool {
|
||||
switch kind {
|
||||
case catalog.OutputPictures:
|
||||
return strings.HasPrefix(ct, "image/") && ct != "image/gif"
|
||||
case catalog.OutputMeshes:
|
||||
return isMeshType(ct)
|
||||
case catalog.OutputVideo:
|
||||
return strings.HasPrefix(ct, "video/") || ct == "image/gif" || ct == "image/webp"
|
||||
case catalog.OutputAudio:
|
||||
return strings.HasPrefix(ct, "audio/")
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, f := range files {
|
||||
if is(f.ContentType) {
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
saved := make([]string, 0, len(files))
|
||||
for _, f := range files {
|
||||
saved = append(saved, path.Base(f.Name)+" ("+f.ContentType+")")
|
||||
}
|
||||
return comfy.Image{}, fmt.Errorf("the workflow saved no %s: it saved %s", kind, strings.Join(saved, ", "))
|
||||
}
|
||||
|
||||
// withLicense is a file with its licence written in, where its format has a
|
||||
// place for one: a GLB's asset.extras, a PNG's text. Any other is returned as
|
||||
// it is, and the licence goes beside it in the job's answer only.
|
||||
func withLicense(data []byte, ct string, lic catalog.License) ([]byte, error) {
|
||||
switch ct {
|
||||
case "model/gltf-binary":
|
||||
return glbWithLicense(data, lic)
|
||||
case "image/png":
|
||||
return pngWithLicense(data, lic)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// pngWithLicense is a PNG with an iTXt chunk "License" after its header,
|
||||
// holding the licence as JSON. The workflow's own text chunks were stripped
|
||||
// when the file was read; this one is the operator's to say.
|
||||
func pngWithLicense(png []byte, lic catalog.License) ([]byte, error) {
|
||||
const sig = "\x89PNG\r\n\x1a\n"
|
||||
if len(png) < len(sig)+25 || string(png[:len(sig)]) != sig || string(png[12:16]) != "IHDR" {
|
||||
return nil, errors.New("not a PNG")
|
||||
}
|
||||
text, err := json.Marshal(lic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// keyword, NUL, compression flag 0, method 0, empty language, NUL, empty
|
||||
// translated keyword, NUL, then the UTF-8 text.
|
||||
var chunk bytes.Buffer
|
||||
chunk.WriteString("iTXt")
|
||||
chunk.WriteString("License\x00\x00\x00\x00\x00")
|
||||
chunk.Write(text)
|
||||
body := chunk.Bytes()
|
||||
out := make([]byte, 0, len(png)+len(body)+8)
|
||||
ihdrEnd := len(sig) + 8 + 13 + 4
|
||||
out = append(out, png[:ihdrEnd]...)
|
||||
out = binary.BigEndian.AppendUint32(out, uint32(len(body)-4)) //nolint:gosec // a licence's JSON
|
||||
out = append(out, body...)
|
||||
out = binary.BigEndian.AppendUint32(out, crc32.ChecksumIEEE(body))
|
||||
return append(out, png[ihdrEnd:]...), nil
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/comfy"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/router/endpoint"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// testWAV is the smallest WAV header and a few samples.
|
||||
func testWAV() []byte {
|
||||
var b bytes.Buffer
|
||||
data := []byte{0, 0, 1, 0, 2, 0, 3, 0}
|
||||
b.WriteString("RIFF")
|
||||
_ = binary.Write(&b, binary.LittleEndian, uint32(36+len(data)))
|
||||
b.WriteString("WAVEfmt ")
|
||||
_ = binary.Write(&b, binary.LittleEndian, []any{uint32(16), uint16(1), uint16(1), uint32(8000), uint32(16000), uint16(2), uint16(16)})
|
||||
b.WriteString("data")
|
||||
_ = binary.Write(&b, binary.LittleEndian, uint32(len(data)))
|
||||
b.Write(data)
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// runComfy is a ComfyUI whose workflow saves what it is told to: a song, a
|
||||
// picture, under whatever key its save node uses.
|
||||
type runComfy struct {
|
||||
srv *httptest.Server
|
||||
mu sync.Mutex
|
||||
uploaded []string
|
||||
workflow map[string]any
|
||||
out []byte
|
||||
outName string
|
||||
}
|
||||
|
||||
func newRunComfy(t *testing.T, out []byte, outName string) *runComfy {
|
||||
c := &runComfy{out: out, outName: outName}
|
||||
c.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/upload/image":
|
||||
f, h, err := r.FormFile("image")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
b, _ := io.ReadAll(f)
|
||||
ext, _ := sniffFile(b)
|
||||
c.mu.Lock()
|
||||
c.uploaded = append(c.uploaded, h.Filename+":"+ext)
|
||||
c.mu.Unlock()
|
||||
fmt.Fprintf(w, `{"name":%q,"subfolder":"","type":"input"}`, h.Filename)
|
||||
case "/prompt":
|
||||
var b struct {
|
||||
Prompt map[string]any `json:"prompt"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&b)
|
||||
c.mu.Lock()
|
||||
c.workflow = b.Prompt
|
||||
c.mu.Unlock()
|
||||
fmt.Fprint(w, `{"prompt_id":"r1"}`)
|
||||
case "/history/r1":
|
||||
fmt.Fprintf(w, `{"r1":{"status":{"status_str":"success","completed":true},"outputs":{
|
||||
"9":{"audio":[{"filename":%q,"subfolder":"","type":"output"}]}}}}`, c.outName)
|
||||
case "/view":
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
_, _ = w.Write(c.out)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(c.srv.Close)
|
||||
return c
|
||||
}
|
||||
|
||||
const songWorkflow = `{
|
||||
"1": {"class_type": "LoadImage", "inputs": {"image": "{{cover}}"}},
|
||||
"2": {"class_type": "TextEncodeAceStepAudio", "inputs": {"tags": "{{tags}}", "lyrics": "{{lyrics|}}", "strength": "{{strength}}"}},
|
||||
"3": {"class_type": "EmptyAceStepLatentAudio", "inputs": {"seconds": "{{seconds}}", "style": "{{style}}"}},
|
||||
"4": {"class_type": "KSampler", "inputs": {"seed": "{{seed}}", "model": "{{checkpoint}}"}},
|
||||
"9": {"class_type": "SaveAudio", "inputs": {"filename_prefix": "audio/song"}}
|
||||
}`
|
||||
|
||||
func f64(v float64) *float64 { return &v }
|
||||
|
||||
func runRouter(t *testing.T, c *runComfy, workflow string, output string) (*Router, string, string) {
|
||||
t.Helper()
|
||||
cfg := baseConfig()
|
||||
cfg.Models = []catalog.Model{
|
||||
{Name: "ace", Source: "external", License: &catalog.License{ID: "Apache-2.0", Name: "Apache 2.0"},
|
||||
External: &catalog.External{URL: c.srv.URL, Model: "ace_step.safetensors", API: catalog.APIComfyUI,
|
||||
Output: output, Workflow: "w.json"},
|
||||
ComfyUI: &catalog.ComfyUI{Inputs: []catalog.Input{
|
||||
{Name: "tags", Type: catalog.InputText, Required: true, Max: f64(200), Doc: "genres and moods"},
|
||||
{Name: "lyrics", Type: catalog.InputText},
|
||||
{Name: "strength", Type: catalog.InputFloat, Min: f64(0), Max: f64(1), Default: 0.7},
|
||||
{Name: "seconds", Type: catalog.InputInt, Min: f64(5), Max: f64(240), Default: 30},
|
||||
{Name: "style", Type: catalog.InputChoice, Choices: []string{"pop", "metal"}, Default: "pop"},
|
||||
{Name: "cover", Type: catalog.InputImage, Required: true},
|
||||
}}},
|
||||
{Name: "flux", Source: "external", External: &catalog.External{URL: c.srv.URL,
|
||||
Model: "flux1-schnell-fp8.safetensors", API: catalog.APIComfyUI}},
|
||||
}
|
||||
cfg.Auth.Enabled = true
|
||||
cfg.Auth.Store = filepath.Join(t.TempDir(), "store.db")
|
||||
cfg.Auth.FlushInterval = 20 * time.Millisecond
|
||||
cfg.Auth.BatchSize = 1
|
||||
rt := newTestRouter(t, cfg)
|
||||
rt.comfy = &comfy.Client{Poll: time.Millisecond}
|
||||
rt.SetEndpoints(t.Context(), []endpoint.Info{
|
||||
{ID: "external:ace", URL: c.srv.URL, Models: []string{"ace"}, Engine: endpoint.EngineComfyUI,
|
||||
Pool: "external", Weight: 1, UpstreamModel: "ace_step.safetensors", Workflow: workflow},
|
||||
{ID: "external:flux", URL: c.srv.URL, Models: []string{"flux"}, Engine: endpoint.EngineComfyUI,
|
||||
Pool: "external", Weight: 1, UpstreamModel: "flux1-schnell-fp8.safetensors"},
|
||||
})
|
||||
org, _ := rt.store.CreateOrg(t.Context(), "studio")
|
||||
team, _ := rt.store.CreateTeam(t.Context(), org, "sound")
|
||||
project, err := rt.store.CreateProject(t.Context(), team, "score")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secret := store.KeyPrefix + strings.Repeat("w", 43)
|
||||
sum := sha256.Sum256([]byte(secret))
|
||||
if err := rt.store.CreateKey(t.Context(), store.Key{ID: "key-w", Hash: sum[:], Prefix: "wwww", Name: "sound",
|
||||
ProjectID: project, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rt, secret, project
|
||||
}
|
||||
|
||||
func TestAWorkflowRunsFromTheInputsItsCatalogEntryDeclares(t *testing.T) {
|
||||
wav := testWAV()
|
||||
c := newRunComfy(t, wav, "song_00001_.wav")
|
||||
rt, secret, _ := runRouter(t, c, songWorkflow, catalog.OutputAudio)
|
||||
h := rt.Handler()
|
||||
|
||||
code, j, raw := call(t, h, secret, http.MethodPost, workflowsPath, map[string]any{"model": "ace", "seed": 5,
|
||||
"inputs": map[string]any{"tags": "synthwave, driving", "seconds": 60, "cover": "data:image/png;base64," + testPicture(t)}})
|
||||
if code != http.StatusAccepted {
|
||||
t.Fatalf("%d %s", code, raw)
|
||||
}
|
||||
got := waitJob(t, h, secret, j["id"].(string), jobDone)
|
||||
if got["status"] != "completed" || got["content_type"] != "audio/wav" {
|
||||
t.Fatalf("job %v", got)
|
||||
}
|
||||
code, _, file := call(t, h, secret, http.MethodGet, "/v1/files/"+got["output_file_id"].(string)+"/content", nil)
|
||||
if code != http.StatusOK || file != string(wav) {
|
||||
t.Fatalf("the result is not the song: %d", code)
|
||||
}
|
||||
c.mu.Lock()
|
||||
wf, _ := json.Marshal(c.workflow)
|
||||
uploaded := strings.Join(c.uploaded, ",")
|
||||
c.mu.Unlock()
|
||||
if !strings.HasPrefix(uploaded, "llmgw-") || !strings.HasSuffix(uploaded, ".png:.png") {
|
||||
t.Errorf("uploaded %q", uploaded)
|
||||
}
|
||||
name := strings.TrimSuffix(uploaded, ":.png")
|
||||
for _, want := range []string{`"image":"` + name + `"`, `"tags":"synthwave, driving"`, `"seconds":60`,
|
||||
`"strength":0.7`, `"style":"pop"`, `"lyrics":""`, `"seed":5`, `"model":"ace_step.safetensors"`} {
|
||||
if !strings.Contains(string(wf), want) {
|
||||
t.Errorf("%s not in the workflow sent: %s", want, wf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunIsRefusedWhatItsWorkflowDoesNotTake(t *testing.T) {
|
||||
c := newRunComfy(t, testWAV(), "song.wav")
|
||||
rt, secret, _ := runRouter(t, c, songWorkflow, catalog.OutputAudio)
|
||||
h := rt.Handler()
|
||||
cover := testPicture(t)
|
||||
in := func(extra map[string]any) map[string]any {
|
||||
m := map[string]any{"tags": "lofi", "cover": cover}
|
||||
for k, v := range extra {
|
||||
m[k] = v
|
||||
}
|
||||
return map[string]any{"model": "ace", "inputs": m}
|
||||
}
|
||||
for name, c := range map[string]struct {
|
||||
body map[string]any
|
||||
want string
|
||||
}{
|
||||
"no tags": {map[string]any{"model": "ace", "inputs": map[string]any{"cover": cover}}, `input "tags" is required: text — genres and moods`},
|
||||
"an unknown input": {in(map[string]any{"tempo": 120}), `takes no input "tempo"`},
|
||||
"a number as text": {in(map[string]any{"tags": 5}), `input "tags" is text`},
|
||||
"too long": {in(map[string]any{"tags": strings.Repeat("x", 201)}), "past 200"},
|
||||
"out of bounds": {in(map[string]any{"strength": 2}), "outside 0 to 1"},
|
||||
"not whole": {in(map[string]any{"seconds": 10.5}), "whole number"},
|
||||
"not a choice": {in(map[string]any{"style": "jazz"}), "one of pop, metal"},
|
||||
"a song as a picture": {in(map[string]any{"cover": base64.StdEncoding.EncodeToString(testWAV())}), "PNG, JPEG"},
|
||||
"someone else's file": {in(map[string]any{"cover": "file-nothere"}), `no file "file-nothere"`},
|
||||
"a run of flux's": {map[string]any{"model": "flux", "inputs": map[string]any{"tags": "x"}}, "takes no inputs"},
|
||||
"a chat with ace": {map[string]any{"model": "ace", "messages": []any{}}, "makes audio: ask for it at " + workflowsPath},
|
||||
} {
|
||||
path := workflowsPath
|
||||
if name == "a chat with ace" {
|
||||
path = "/v1/chat/completions"
|
||||
}
|
||||
code, _, raw := call(t, h, secret, http.MethodPost, path, c.body)
|
||||
if code != http.StatusBadRequest || !strings.Contains(errorMessage([]byte(raw)), c.want) {
|
||||
t.Errorf("%s: %d %s", name, code, raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunTakesAFileUploadedBeforeAndNamedByItsID(t *testing.T) {
|
||||
c := newRunComfy(t, testWAV(), "song.wav")
|
||||
rt, secret, project := runRouter(t, c, songWorkflow, catalog.OutputAudio)
|
||||
h := rt.Handler()
|
||||
pic, _ := base64.StdEncoding.DecodeString(testPicture(t))
|
||||
now := time.Now()
|
||||
if err := rt.store.PutFile(t.Context(), store.File{ID: "file-cover", Owner: "project:" + project, Purpose: "user_data",
|
||||
Filename: "cover.png", Bytes: int64(len(pic)), CreatedAt: now, ExpiresAt: now.Add(time.Hour)}, pic); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := rt.store.PutFile(t.Context(), store.File{ID: "file-theirs", Owner: "project:other", Purpose: "user_data",
|
||||
Filename: "x.png", Bytes: int64(len(pic)), CreatedAt: now, ExpiresAt: now.Add(time.Hour)}, pic); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, _, raw := call(t, h, secret, http.MethodPost, workflowsPath,
|
||||
map[string]any{"model": "ace", "inputs": map[string]any{"tags": "x", "cover": "file-theirs"}})
|
||||
if code != http.StatusBadRequest || !strings.Contains(errorMessage([]byte(raw)), `no file "file-theirs"`) {
|
||||
t.Errorf("another project's file: %d %s", code, raw)
|
||||
}
|
||||
code, j, raw := call(t, h, secret, http.MethodPost, workflowsPath,
|
||||
map[string]any{"model": "ace", "inputs": map[string]any{"tags": "x", "cover": "file-cover"}})
|
||||
if code != http.StatusAccepted {
|
||||
t.Fatalf("%d %s", code, raw)
|
||||
}
|
||||
if got := waitJob(t, h, secret, j["id"].(string), jobDone); got["status"] != "completed" {
|
||||
t.Fatalf("job %v", got)
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(c.uploaded) != 1 || !strings.HasSuffix(c.uploaded[0], ".png:.png") {
|
||||
t.Errorf("uploaded %v", c.uploaded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWorkflowWantingWhatNoInputGivesFailsSayingSo(t *testing.T) {
|
||||
c := newRunComfy(t, testWAV(), "song.wav")
|
||||
wf := strings.Replace(songWorkflow, `"{{style}}"`, `"{{mood}}"`, 1)
|
||||
rt, secret, _ := runRouter(t, c, wf, catalog.OutputAudio)
|
||||
h := rt.Handler()
|
||||
code, j, raw := call(t, h, secret, http.MethodPost, workflowsPath,
|
||||
map[string]any{"model": "ace", "inputs": map[string]any{"tags": "x", "cover": testPicture(t)}})
|
||||
if code != http.StatusAccepted {
|
||||
t.Fatalf("%d %s", code, raw)
|
||||
}
|
||||
got := waitJob(t, h, secret, j["id"].(string), jobDone)
|
||||
if e, _ := got["error"].(map[string]any); got["status"] != "failed" || !strings.Contains(fmt.Sprint(e["message"]), "placeholders mood") {
|
||||
t.Errorf("job %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestARunThatSavedTheWrongKindFailsAndAPictureCarriesItsLicence(t *testing.T) {
|
||||
c := newRunComfy(t, testWAV(), "song.wav")
|
||||
rt, secret, _ := runRouter(t, c, songWorkflow, catalog.OutputVideo)
|
||||
h := rt.Handler()
|
||||
_, j, _ := call(t, h, secret, http.MethodPost, workflowsPath,
|
||||
map[string]any{"model": "ace", "inputs": map[string]any{"tags": "x", "cover": testPicture(t)}})
|
||||
got := waitJob(t, h, secret, j["id"].(string), jobDone)
|
||||
if e, _ := got["error"].(map[string]any); !strings.Contains(fmt.Sprint(e["message"]), "saved no video: it saved song.wav (audio/wav)") {
|
||||
t.Errorf("job %v", got)
|
||||
}
|
||||
|
||||
var b bytes.Buffer
|
||||
_ = png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 4, 4)))
|
||||
with, err := pngWithLicense(b.Bytes(), catalog.License{ID: "Apache-2.0", Name: "Apache 2.0", Attribution: "Made with ACE"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := png.Decode(bytes.NewReader(with)); err != nil {
|
||||
t.Fatalf("not a PNG any more: %v", err)
|
||||
}
|
||||
if !bytes.Contains(with, []byte("iTXtLicense\x00\x00\x00\x00\x00{\"id\":\"Apache-2.0\"")) {
|
||||
t.Error("no licence chunk in the picture")
|
||||
}
|
||||
if _, err := pngWithLicense([]byte("GIF89a"), catalog.License{Name: "x"}); err == nil {
|
||||
t.Error("a GIF taken for a PNG")
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,9 @@ var copyOrder = []string{
|
||||
"users",
|
||||
// After users and the three levels of the tree, which it points at.
|
||||
"memberships",
|
||||
// After users, which it points at.
|
||||
"user_excluded_licences",
|
||||
"preset_state",
|
||||
"api_keys",
|
||||
"key_claims",
|
||||
"sessions",
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// UserExcludedLicences is what a person has said not to use, sorted: a
|
||||
// licence by its id, or a rule over licences ("rule:non-commercial",
|
||||
// "rule:region:EU"). What each means is the router's to match; the store
|
||||
// keeps the words.
|
||||
func (s *DB) UserExcludedLicences(ctx context.Context, userID string) ([]string, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT licence FROM user_excluded_licences WHERE user_id = ? ORDER BY licence`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
var out []string
|
||||
for rows.Next() {
|
||||
var l string
|
||||
if err := rows.Scan(&l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// SetUserExcludedLicences replaces what a person has said not to use; an
|
||||
// empty list excludes nothing.
|
||||
func (s *DB) SetUserExcludedLicences(ctx context.Context, userID string, licences []string) error {
|
||||
keys := make([]string, 0, len(licences))
|
||||
for _, l := range licences {
|
||||
if l = strings.TrimSpace(l); l != "" {
|
||||
keys = append(keys, l)
|
||||
}
|
||||
}
|
||||
slices.Sort(keys)
|
||||
keys = slices.Compact(keys)
|
||||
return s.inTx(ctx, func(c conn) error {
|
||||
if _, err := c.ExecContext(ctx, `DELETE FROM user_excluded_licences WHERE user_id = ?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if _, err := c.ExecContext(ctx,
|
||||
`INSERT INTO user_excluded_licences (user_id, licence) VALUES (?, ?)`, userID, k); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAPersonsExcludedLicencesReachEveryWayTheyAsk(t *testing.T) {
|
||||
db := openDB(t)
|
||||
ctx := t.Context()
|
||||
if err := db.CreateUser(ctx, User{ID: "usr-1", Email: "a@b.c", State: UserActive, CreatedAt: time.Now()}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SetUserExcludedLicences(ctx, "usr-1", []string{"tencent-hunyuan-community", " CC-BY-NC-4.0", "", "CC-BY-NC-4.0", "rule:region:EU"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.UserExcludedLicences(ctx, "usr-1")
|
||||
want := []string{"CC-BY-NC-4.0", "rule:region:EU", "tencent-hunyuan-community"}
|
||||
if err != nil || !slices.Equal(got, want) {
|
||||
t.Errorf("excluded %v %v, want %v", got, err, want)
|
||||
}
|
||||
|
||||
// A session, which has no key row, gets them as a key does.
|
||||
eff, err := db.ResolveFor(ctx, "session:usr-1", "usr-1", "")
|
||||
if err != nil || !slices.Equal(eff.ExcludedLicences, want) {
|
||||
t.Errorf("session: %v %v", eff.ExcludedLicences, err)
|
||||
}
|
||||
|
||||
if err := db.SetUserExcludedLicences(ctx, "usr-1", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := db.UserExcludedLicences(ctx, "usr-1"); len(got) != 0 {
|
||||
t.Errorf("cleared, still %v", got)
|
||||
}
|
||||
}
|
||||
@@ -224,6 +224,13 @@ func (s *DB) ResolveFor(ctx context.Context, callerID, userID, projectID string)
|
||||
// person's limit by every key they own; a per-member limit by one person
|
||||
// within one scope.
|
||||
func (s *DB) resolveChain(ctx context.Context, e *Effective, keyLevel bool) error {
|
||||
if e.UserID != "" {
|
||||
ex, err := s.UserExcludedLicences(ctx, e.UserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
e.ExcludedLicences = ex
|
||||
}
|
||||
if e.ProjectID != "" {
|
||||
err := s.db.QueryRowContext(ctx,
|
||||
`SELECT t.id, t.org_id FROM projects p JOIN teams t ON t.id = p.team_id WHERE p.id = ?`,
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
-- Ported from the SQLite migration of the same number.
|
||||
-- The licences a person has said not to use. Models under them are not
|
||||
-- listed to the person, nor served to their keys or their sessions: a
|
||||
-- game sold in the EU cannot ship what a licence excluding the EU made.
|
||||
CREATE TABLE user_excluded_licences (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
licence TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, licence)
|
||||
);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Ported from the SQLite migration of the same number.
|
||||
-- The models that come with llmgw, as they stand in this cluster: who added
|
||||
-- one and when ("auto" for the leader), and whether somebody removed it
|
||||
-- since, so that the leader does not add it back by itself.
|
||||
CREATE TABLE preset_state (
|
||||
name TEXT PRIMARY KEY,
|
||||
added_by TEXT NOT NULL DEFAULT '',
|
||||
added_at BIGINT NOT NULL DEFAULT 0,
|
||||
declined BIGINT NOT NULL DEFAULT 0,
|
||||
declined_by TEXT NOT NULL DEFAULT '',
|
||||
declined_at BIGINT NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
-- The licences a person has said not to use. Models under them are not
|
||||
-- listed to the person, nor served to their keys or their sessions: a
|
||||
-- game sold in the EU cannot ship what a licence excluding the EU made.
|
||||
CREATE TABLE user_excluded_licences (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
licence TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, licence)
|
||||
);
|
||||
@@ -0,0 +1,11 @@
|
||||
-- The models that come with llmgw, as they stand in this cluster: who added
|
||||
-- one and when ("auto" for the leader), and whether somebody removed it
|
||||
-- since, so that the leader does not add it back by itself.
|
||||
CREATE TABLE preset_state (
|
||||
name TEXT PRIMARY KEY,
|
||||
added_by TEXT NOT NULL DEFAULT '',
|
||||
added_at INTEGER NOT NULL DEFAULT 0,
|
||||
declined INTEGER NOT NULL DEFAULT 0,
|
||||
declined_by TEXT NOT NULL DEFAULT '',
|
||||
declined_at INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
@@ -0,0 +1,72 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PresetState is one preset as it stands in this cluster.
|
||||
type PresetState struct {
|
||||
Name string
|
||||
// AddedBy is "auto" when the leader added it, who did otherwise.
|
||||
AddedBy string
|
||||
AddedAt time.Time
|
||||
// Declined is somebody removing it after it was added: the leader does
|
||||
// not add it back by itself.
|
||||
Declined bool
|
||||
DeclinedBy string
|
||||
DeclinedAt time.Time
|
||||
}
|
||||
|
||||
// PresetAdder is the AddedBy of a preset the leader added by itself.
|
||||
const PresetAdder = "auto"
|
||||
|
||||
// PresetStates is every preset that was ever added or declined, by name.
|
||||
func (s *DB) PresetStates(ctx context.Context) (map[string]PresetState, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT name, added_by, added_at, declined, declined_by, declined_at FROM preset_state`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = rows.Close() }()
|
||||
out := map[string]PresetState{}
|
||||
for rows.Next() {
|
||||
var p PresetState
|
||||
var added, declinedAt int64
|
||||
var declined int
|
||||
if err := rows.Scan(&p.Name, &p.AddedBy, &added, &declined, &p.DeclinedBy, &declinedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.AddedAt, p.Declined, p.DeclinedAt = presetTime(added), declined != 0, presetTime(declinedAt)
|
||||
out[p.Name] = p
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// PresetAdded records a preset added, by whom, and clears its decline.
|
||||
func (s *DB) PresetAdded(ctx context.Context, name, by string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO preset_state (name, added_by, added_at, declined, declined_by, declined_at) VALUES (?, ?, ?, 0, '', 0)
|
||||
ON CONFLICT (name) DO UPDATE SET added_by = excluded.added_by, added_at = excluded.added_at,
|
||||
declined = 0, declined_by = '', declined_at = 0`,
|
||||
name, by, at.UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
// PresetDeclined records a preset removed after it was added, so the leader
|
||||
// does not add it back.
|
||||
func (s *DB) PresetDeclined(ctx context.Context, name, by string, at time.Time) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO preset_state (name, declined, declined_by, declined_at) VALUES (?, 1, ?, ?)
|
||||
ON CONFLICT (name) DO UPDATE SET declined = 1, declined_by = excluded.declined_by, declined_at = excluded.declined_at`,
|
||||
name, by, at.UnixMilli())
|
||||
return err
|
||||
}
|
||||
|
||||
// presetTime is a stored instant, zero for none.
|
||||
func presetTime(ms int64) time.Time {
|
||||
if ms == 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
return fromMillis(ms)
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAPresetDeclinedStaysSoUntilAddedAgain(t *testing.T) {
|
||||
db := openDB(t)
|
||||
ctx := t.Context()
|
||||
at := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC)
|
||||
if err := db.PresetAdded(ctx, "triposr", PresetAdder, at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.PresetDeclined(ctx, "triposr", "ops@example.com", at.Add(time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Removed before it was ever added as a preset: declined all the same.
|
||||
if err := db.PresetDeclined(ctx, "flux-schnell", "ops@example.com", at); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := db.PresetStates(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p := got["triposr"]; p.AddedBy != PresetAdder || !p.AddedAt.Equal(at) || !p.Declined || p.DeclinedBy != "ops@example.com" {
|
||||
t.Errorf("triposr %+v", p)
|
||||
}
|
||||
if p := got["flux-schnell"]; !p.Declined || p.AddedBy != "" || !p.AddedAt.IsZero() {
|
||||
t.Errorf("flux %+v", p)
|
||||
}
|
||||
if err := db.PresetAdded(ctx, "triposr", "ops@example.com", at.Add(2*time.Hour)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, _ = db.PresetStates(ctx)
|
||||
if p := got["triposr"]; p.Declined || p.AddedBy != "ops@example.com" || !p.DeclinedAt.IsZero() {
|
||||
t.Errorf("added again, %+v", p)
|
||||
}
|
||||
}
|
||||
@@ -165,6 +165,9 @@ type Effective struct {
|
||||
BudgetPeriod Period
|
||||
// AllowedModels is the intersection along the chain. nil means every model.
|
||||
AllowedModels []string
|
||||
// ExcludedLicences are the licences the caller's person has said not to
|
||||
// use, as LicenceKey gives them; none for a key without a person.
|
||||
ExcludedLicences []string
|
||||
|
||||
// RPMOwner, TPMOwner and BudgetOwner say which scope set the value that
|
||||
// won, so consumption can be counted against that scope rather than
|
||||
|
||||
+14
-3
@@ -95,9 +95,13 @@ type AccountPages struct {
|
||||
hasSettings bool
|
||||
// images says whether some model makes pictures; nil when the
|
||||
// pictures page is not mounted.
|
||||
images func() bool
|
||||
meshes func() bool
|
||||
guardLibrary func() guard.Config
|
||||
// licenceChoices and licencePrefs offer the choice of licences on the
|
||||
// profile page; nil offers none.
|
||||
licenceChoices func() LicenceChoices
|
||||
licencePrefs LicencePrefs
|
||||
images func() bool
|
||||
meshes func() bool
|
||||
guardLibrary func() guard.Config
|
||||
// guardFor names the guardrail policies in force for someone, and
|
||||
// whether they keep nothing; nil when there are none.
|
||||
guardFor func(guard.Subject) (string, bool)
|
||||
@@ -193,6 +197,7 @@ func (p *AccountPages) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /profile/keys", p.selfAudited(p.createKey))
|
||||
mux.HandleFunc("POST /profile/keys/{id}/revoke", p.selfAudited(p.revokeKey))
|
||||
mux.HandleFunc("POST /profile/password", p.selfAudited(p.changePassword))
|
||||
mux.HandleFunc("POST /profile/licences", p.selfAudited(p.setLicences))
|
||||
mux.HandleFunc("POST /profile/connect", p.selfAudited(p.connect))
|
||||
mux.HandleFunc("GET /setup/{tool}", p.setupScript)
|
||||
}
|
||||
@@ -379,6 +384,11 @@ type profileData struct {
|
||||
Models []string
|
||||
Error string
|
||||
|
||||
// Licences is the choice of licences not to use; nil when not offered.
|
||||
Licences *LicenceChoices
|
||||
LicencesSaved bool
|
||||
LicenceError string
|
||||
|
||||
PasswordChanged bool
|
||||
PasswordError string
|
||||
MinPassword int
|
||||
@@ -505,6 +515,7 @@ func (p *AccountPages) render(w http.ResponseWriter, r *http.Request,
|
||||
}
|
||||
d.Keys = keys
|
||||
p.fillPlaces(r.Context(), langOf(r), user, &d)
|
||||
p.fillLicences(r.Context(), user.ID, &d)
|
||||
render(w, r, "profile", d)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ func buildNotice(l *Lang, node, engine, build string) string {
|
||||
case "none":
|
||||
return l.T("nodeengine.notice.none", node, engine)
|
||||
}
|
||||
name := map[string]string{"cu13": "CUDA 13", "cu129": "CUDA 12.9", "cu12": "CUDA 12"}[build]
|
||||
name := map[string]string{"cu13": "CUDA 13", "cu129": "CUDA 12.9", "cu126": "CUDA 12.6", "cu12": "CUDA 12"}[build]
|
||||
if name == "" {
|
||||
name = build
|
||||
}
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// PresetAdmin is the models that come with llmgw, as the cluster page shows
|
||||
// and adds them: flux-schnell for pictures, triposr for 3D models. A site
|
||||
// whose catalog had neither showed no Images or 3D page, and nothing said
|
||||
// they were one click away.
|
||||
type PresetAdmin interface {
|
||||
Presets(ctx context.Context) (adminapi.PresetsView, error)
|
||||
AddPreset(ctx context.Context, name, by string) error
|
||||
SetAutoPresets(ctx context.Context, on bool, by string) error
|
||||
}
|
||||
|
||||
// presetsCard is the presets as the cluster page shows them.
|
||||
type presetsCard struct {
|
||||
Auto bool
|
||||
Rows []presetRow
|
||||
}
|
||||
|
||||
// presetRow is one preset, its words already in the reader's language.
|
||||
type presetRow struct {
|
||||
Name, Description, Makes, Card, Licence string
|
||||
// Fits says which of the cluster's classes can run it; empty when none.
|
||||
Fits string
|
||||
// State is where it stands: added, and by whom; removed, so not added
|
||||
// again by itself; or neither.
|
||||
State string
|
||||
InCatalog bool
|
||||
Declined bool
|
||||
// Blocked is why the leader does not add it by itself, in the control
|
||||
// plane's words (English); not said when automatic adding is off, which
|
||||
// the line under the table says once.
|
||||
Blocked string
|
||||
// AddsComfyUI is an Add that also has the GPU nodes install ComfyUI and
|
||||
// the control planes restart on that setting.
|
||||
AddsComfyUI bool
|
||||
}
|
||||
|
||||
// presetAddsComfyUI is whether adding a preset by hand sets engines.comfyui:
|
||||
// the cluster installs none yet.
|
||||
func presetAddsComfyUI(v adminapi.PresetsView, p adminapi.PresetView) bool {
|
||||
return !p.InCatalog && v.ComfyUI == ""
|
||||
}
|
||||
|
||||
func init() {
|
||||
registerKeys("dash.presets.makes.pictures", "dash.presets.makes.meshes")
|
||||
}
|
||||
|
||||
// presetsCardOf reads the presets for the page; nil, and the card left off,
|
||||
// where the control plane cannot say.
|
||||
func presetsCardOf(ctx context.Context, l *Lang, a PresetAdmin) *presetsCard {
|
||||
v, err := a.Presets(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
c := &presetsCard{Auto: v.Auto}
|
||||
for _, p := range v.Presets {
|
||||
row := presetRow{
|
||||
Name: p.Name, Description: p.Description, Card: p.Card,
|
||||
Makes: l.T("dash.presets.makes." + p.Makes),
|
||||
Licence: p.Licence.Name,
|
||||
Fits: strings.Join(p.FitsClasses, ", "),
|
||||
InCatalog: p.InCatalog, Declined: p.Declined,
|
||||
AddsComfyUI: presetAddsComfyUI(v, p),
|
||||
}
|
||||
if !strings.HasPrefix(p.Blocked, "presets.auto") && !p.Declined {
|
||||
row.Blocked = p.Blocked
|
||||
}
|
||||
if c := p.Licence.Commercial; c != nil && !*c {
|
||||
row.Licence = l.T("dash.presets.not_commercial", p.Licence.Name)
|
||||
}
|
||||
at := ""
|
||||
if !p.AddedAt.IsZero() {
|
||||
at = p.AddedAt.UTC().Format("2006-01-02 15:04")
|
||||
}
|
||||
switch {
|
||||
case p.InCatalog && p.AddedBy == "auto":
|
||||
row.State = l.T("dash.presets.added_auto", at)
|
||||
case p.InCatalog && p.AddedBy != "":
|
||||
row.State = l.T("dash.presets.added_by", p.AddedBy, at)
|
||||
case p.InCatalog:
|
||||
row.State = l.T("dash.presets.in_catalog")
|
||||
case p.Declined:
|
||||
row.State = l.T("dash.presets.declined")
|
||||
}
|
||||
c.Rows = append(c.Rows, row)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// RegisterPresets mounts adding a preset, and turning their automatic adding
|
||||
// on and off, on the cluster page: for an administrator, with the session's
|
||||
// CSRF token, answered by going back to the page with what happened.
|
||||
func (p *AccountPages) RegisterPresets(mux *http.ServeMux, a PresetAdmin) {
|
||||
back := func(w http.ResponseWriter, r *http.Request, q url.Values) {
|
||||
http.Redirect(w, r, "/admin?"+q.Encode()+"#presets", http.StatusSeeOther)
|
||||
}
|
||||
checked := func(h func(w http.ResponseWriter, r *http.Request, admin store.User)) http.HandlerFunc {
|
||||
return p.adminOnly(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
||||
sess, _, _ := p.session(r)
|
||||
if !p.accounts.CheckCSRF(sess, r.FormValue("csrf")) {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
h(w, r, admin)
|
||||
})
|
||||
}
|
||||
// Before the one by name, which would otherwise take "auto" for a preset.
|
||||
mux.HandleFunc("POST /admin/presets/auto", checked(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
||||
on := r.FormValue("on") == "true"
|
||||
q := url.Values{}
|
||||
if err := a.SetAutoPresets(r.Context(), on, admin.Email); err != nil {
|
||||
p.log.Warn("presets.auto was not changed", "admin", admin.Email, "err", err)
|
||||
q.Set("problem", err.Error())
|
||||
} else {
|
||||
p.log.Info("presets.auto changed", "admin", admin.Email, "on", on)
|
||||
key := "dash.presets.notice.auto_off"
|
||||
if on {
|
||||
key = "dash.presets.notice.auto_on"
|
||||
}
|
||||
q.Set("notice", langOf(r).T(key))
|
||||
}
|
||||
back(w, r, q)
|
||||
}))
|
||||
mux.HandleFunc("POST /admin/presets/{name}", checked(func(w http.ResponseWriter, r *http.Request, admin store.User) {
|
||||
name := r.PathValue("name")
|
||||
q := url.Values{}
|
||||
if err := a.AddPreset(r.Context(), name, admin.Email); err != nil {
|
||||
p.log.Warn("a preset was not added", "admin", admin.Email, "preset", name, "err", err)
|
||||
// The control plane's reason, as it gave it: in English.
|
||||
q.Set("problem", name+": "+err.Error())
|
||||
} else {
|
||||
p.log.Info("a preset was added", "admin", admin.Email, "preset", name)
|
||||
q.Set("notice", langOf(r).T("dash.presets.notice.added", name))
|
||||
}
|
||||
back(w, r, q)
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// presetFleet is a cluster whose control plane says its presets: triposr
|
||||
// added by itself, flux-schnell removed by somebody.
|
||||
type presetFleet struct {
|
||||
fakeFleet
|
||||
view adminapi.PresetsView
|
||||
err error
|
||||
added []string
|
||||
auto []bool
|
||||
}
|
||||
|
||||
func (f *presetFleet) Presets(context.Context) (adminapi.PresetsView, error) { return f.view, f.err }
|
||||
|
||||
func (f *presetFleet) AddPreset(_ context.Context, name, by string) error {
|
||||
f.added = append(f.added, name+" by "+by)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *presetFleet) SetAutoPresets(_ context.Context, on bool, _ string) error {
|
||||
f.auto = append(f.auto, on)
|
||||
return nil
|
||||
}
|
||||
|
||||
func newPresetFleet() *presetFleet {
|
||||
no := false
|
||||
return &presetFleet{view: adminapi.PresetsView{Auto: true, Presets: []adminapi.PresetView{
|
||||
{Name: "flux-schnell", Makes: "pictures", Card: "cc 7.0+, 24 GB+", Licence: catalog.License{Name: "Apache-2.0"},
|
||||
FitsClasses: []string{"v100-32g"}, Fits: true, Declined: true,
|
||||
Blocked: "removed from the catalog after it was added; add it again to undo"},
|
||||
{Name: "sdxl", Makes: "pictures", Card: "cc 8.0+", Licence: catalog.License{Name: "x"},
|
||||
Blocked: "the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that"},
|
||||
{Name: "triposr", Makes: "meshes", Card: "cc 7.0+, 8 GB+", Licence: catalog.License{Name: "Made-up", Commercial: &no},
|
||||
FitsClasses: []string{"t4-15g", "v100-32g"}, Fits: true, InCatalog: true, AddedBy: "auto",
|
||||
AddedAt: time.Date(2026, 10, 13, 9, 30, 0, 0, time.UTC)},
|
||||
}}}
|
||||
}
|
||||
|
||||
func TestThePicturesAnd3DModelsAreAddedFromTheClusterPage(t *testing.T) {
|
||||
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
|
||||
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
f := newPresetFleet()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /admin", p.AdminPage(Dashboard(f, nil, "v1", time.Second, false, NavExtras{}, nil)))
|
||||
p.RegisterPresets(mux, f)
|
||||
|
||||
body := mirrorGet(t, mux, "/admin").Body.String()
|
||||
for _, want := range []string{
|
||||
`id="presets"`, "Pictures and 3D models",
|
||||
`action="/admin/presets/flux-schnell"`, "Add again", "removed, so not added again by itself",
|
||||
"added automatically, 2026-10-13 09:30", "Made-up, not for commercial use", "t4-15g, v100-32g",
|
||||
`action="/admin/presets/auto"`, `name="on" value="false"`, "Stop adding them",
|
||||
"(the cluster installs no ComfyUI (engines.comfyui); adding it by hand sets that)",
|
||||
"restarts the control planes",
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("%q not on the page", want)
|
||||
}
|
||||
}
|
||||
// The button's title and the line beside it, on each preset not here:
|
||||
// the cluster installs no ComfyUI yet.
|
||||
if n := strings.Count(body, "restarts the control planes"); n != 4 {
|
||||
t.Errorf("the ComfyUI warning is said %d times, not on the two presets an Add would install it for", n)
|
||||
}
|
||||
if strings.Contains(body, "(removed from the catalog") {
|
||||
t.Error("a decline is said twice")
|
||||
}
|
||||
if strings.Contains(body, `action="/admin/presets/triposr"`) {
|
||||
t.Error("a preset in the catalog is offered to be added")
|
||||
}
|
||||
|
||||
w := mirrorPost(t, mux, "/admin/presets/flux-schnell", url.Values{"csrf": {"tok"}})
|
||||
if w.Code != http.StatusSeeOther || len(f.added) != 1 || f.added[0] != "flux-schnell by a@example.com" {
|
||||
t.Fatalf("status %d, added %v", w.Code, f.added)
|
||||
}
|
||||
if loc := w.Header().Get("Location"); !strings.Contains(loc, "notice=") || !strings.HasSuffix(loc, "#presets") {
|
||||
t.Errorf("redirected to %s", loc)
|
||||
}
|
||||
w = mirrorPost(t, mux, "/admin/presets/auto", url.Values{"csrf": {"tok"}, "on": {"false"}})
|
||||
if w.Code != http.StatusSeeOther || len(f.auto) != 1 || f.auto[0] || len(f.added) != 1 {
|
||||
t.Fatalf("status %d, auto %v, added %v: auto was taken for a preset's name", w.Code, f.auto, f.added)
|
||||
}
|
||||
if w := mirrorPost(t, mux, "/admin/presets/triposr", url.Values{"csrf": {"bad"}}); w.Code != http.StatusBadRequest || len(f.added) != 1 {
|
||||
t.Errorf("a forged form was accepted: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoComfyUIWarningWhereTheClusterInstallsIt(t *testing.T) {
|
||||
f := newPresetFleet()
|
||||
f.view.ComfyUI = "0.39.2"
|
||||
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
|
||||
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /admin", p.AdminPage(Dashboard(f, nil, "v1", time.Second, false, NavExtras{}, nil)))
|
||||
body := mirrorGet(t, mux, "/admin").Body.String()
|
||||
if !strings.Contains(body, `action="/admin/presets/flux-schnell"`) {
|
||||
t.Fatal("no Add on the page")
|
||||
}
|
||||
if strings.Contains(body, "restarts the control planes") {
|
||||
t.Error("warned of installing a ComfyUI the cluster already installs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePresetsCardIsLeftOffWhereTheControlPlaneCannotSay(t *testing.T) {
|
||||
f := newPresetFleet()
|
||||
f.err = errors.New("501")
|
||||
_, body := get(t, Dashboard(f, nil, "v1", time.Second, true, NavExtras{}, nil), "http://127.0.0.1:9090/", nil)
|
||||
if strings.Contains(body, `id="presets"`) {
|
||||
t.Error("an empty presets card was drawn")
|
||||
}
|
||||
// The read-only page shows them, with nothing to press.
|
||||
f.err = nil
|
||||
_, body = get(t, Dashboard(f, nil, "v1", time.Second, true, NavExtras{}, nil), "http://127.0.0.1:9090/", nil)
|
||||
if !strings.Contains(body, `id="presets"`) || strings.Contains(body, `/admin/presets/`) {
|
||||
t.Errorf("the admin listener's page: card %v, forms %v",
|
||||
strings.Contains(body, `id="presets"`), strings.Contains(body, `/admin/presets/`))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type apiDocsData struct {
|
||||
Version string
|
||||
Nav Nav
|
||||
SignedIn bool
|
||||
SignIn bool
|
||||
// Specs are the documents the page offers, as Swagger UI's urls: the
|
||||
// client API's to everyone, the admin API's to staff.
|
||||
Specs template.JS
|
||||
}
|
||||
|
||||
// APIDocs renders the APIs' reference: their OpenAPI documents in Swagger
|
||||
// UI, served from the gateway like every other script here.
|
||||
func APIDocs(version string, signIn bool) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
d := apiDocsData{Version: version, SignIn: signIn}
|
||||
if n, ok := r.Context().Value(navKey{}).(Nav); ok {
|
||||
n.Current = "docs"
|
||||
d.Nav, d.SignedIn = n, true
|
||||
}
|
||||
specs := []map[string]string{{"name": "client API (/v1)", "url": "/v1/openapi.json"}}
|
||||
if d.Nav.Admin {
|
||||
// The admin API is on its own listener, which a browser here
|
||||
// does not reach: its document is offered to read, not to try.
|
||||
specs = append(specs, map[string]string{"name": "admin API (admin listener)", "url": "/docs/api/admin.json"})
|
||||
}
|
||||
b, _ := json.Marshal(specs)
|
||||
d.Specs = template.JS(b) //nolint:gosec // our own JSON
|
||||
render(w, r, "apidocs", d)
|
||||
}
|
||||
}
|
||||
@@ -36,8 +36,11 @@ type DashboardData struct {
|
||||
Models []adminapi.ModelView
|
||||
Replicas []adminapi.ReplicaView
|
||||
Unplaced []adminapi.ExplainView
|
||||
Usage []store.KeyUsage
|
||||
Summary adminapi.Summary
|
||||
// Presets is the models that come with llmgw — pictures, 3D models —
|
||||
// and whether each is here; nil where the control plane cannot say.
|
||||
Presets *presetsCard
|
||||
Usage []store.KeyUsage
|
||||
Summary adminapi.Summary
|
||||
// Fabrics is the physical fabrics and what disagrees about them, from a
|
||||
// source that can say.
|
||||
Fabrics *fabrics.Report
|
||||
@@ -236,6 +239,9 @@ func Dashboard(src Source, usage UsageSource, version string, refresh time.Durat
|
||||
}
|
||||
}
|
||||
d.Unplaced = unplaced(src, d.Models)
|
||||
if pa, ok := src.(PresetAdmin); ok {
|
||||
d.Presets = presetsCardOf(r.Context(), l, pa)
|
||||
}
|
||||
ids := make([]string, 0, len(d.Nodes))
|
||||
for _, n := range d.Nodes {
|
||||
ids = append(ids, n.ID)
|
||||
|
||||
Loaded 100 of 120 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user