Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55d5e13534 | ||
|
|
7c53380e68 | ||
|
|
0460f52dd2 | ||
|
|
efffdb66b7 | ||
|
|
f5eb6963af | ||
|
|
1e7086e13e | ||
|
|
a9a2360f44 | ||
|
|
e78075876b | ||
|
|
45666e72db | ||
|
|
2eb2a6d8fe | ||
|
|
cfc636e328 | ||
|
|
848db08469 | ||
|
|
f42af27da5 |
No files matched your search
+19
-3
@@ -1385,7 +1385,7 @@ gate, so no token is on the node.
|
||||
- Done when: SF3D runs on cuda01 with nothing installed into the engine's
|
||||
venv by hand (the hand install of 2026-10-10 removed first).
|
||||
|
||||
### H5. Licence on every output (S)
|
||||
### H5. Licence on every output (S) — done 2026-10-14: the whole licence in GLB asset.extras and PNG iTXt; video and audio not yet
|
||||
- `License` gains `id` (SPDX where there is one, else a lowercase name:
|
||||
stability-community, tencent-hunyuan-community), `commercial` (unset is
|
||||
not said), `excluded_regions` (ISO 3166 codes, `EU` for the union),
|
||||
@@ -1398,7 +1398,7 @@ gate, so no token is on the node.
|
||||
audio formats in their metadata where ffmpeg is at hand on the router,
|
||||
otherwise beside it in the job's answer only — said in the docs.
|
||||
|
||||
### H6. Licence choices (S) — the other session's (llmgw-b7), at the user's request
|
||||
### H6. Licence choices (S) — done 2026-10-11 (4bbac08), live on the PoC
|
||||
The user asked (2026-10-11) for the choice to be a person's: on their
|
||||
profile they pick the licences they will not use, and their keys and
|
||||
portal session neither see those models in `/v1/models` nor may call
|
||||
@@ -1407,13 +1407,29 @@ them (403, saying which licence). Matched on `license.id`, and on
|
||||
store table is migration 0046. A site-wide or team-wide default can sit
|
||||
on top of it later.
|
||||
|
||||
### H7. A job worker per model, not one for all (S)
|
||||
### H7. A job worker per model, not one for all (S) — done 2026-10-14: one job per model at a time, eight in all
|
||||
The worker runs one job at a time: a job waiting for the card holds up a
|
||||
job for another model that could run now (seen on the PoC, 2026-10-10).
|
||||
Jobs are claimed and run concurrently up to a limit per model (or per
|
||||
environment, after H3) and a cluster-wide cap, with the lease and
|
||||
leader-only rules of G1 kept.
|
||||
|
||||
### H8. Presets that add themselves (S) — done 2026-10-13 (v0.14.5)
|
||||
flux-schnell and triposr come with llmgw (`ctl model add <preset>`), and
|
||||
the leader adds them on demand where a node fits one, the cluster installs
|
||||
ComfyUI and the catalog makes nothing of that kind (`presets.auto`); a
|
||||
preset removed is declined and not added back. The cluster page and the
|
||||
console show and add them.
|
||||
|
||||
### H9. Small things learned on the PoC (2026-10-14)
|
||||
- Done: `albedo_match` on sf3d (its delighting made dark objects light);
|
||||
`on_demand.evict_after_idle` / `min_lifetime` as live settings; an
|
||||
upgrade that leaves nodes behind says which; `ctl model serve` serves an
|
||||
embedding model for embedding; refreshes wait while a form is filled in.
|
||||
- Open: llmgw's `/mcp` with picture, 3D, VLM and workflow tools, so a
|
||||
client needs no server of its own (asked by the game team); the shared
|
||||
ComfyUI replica (H3's placement side).
|
||||
|
||||
## D. Facts worth knowing before starting
|
||||
|
||||
- Admin API: `127.0.0.1:9090`, no auth, `ctlClient` in `internal/cli/ctl.go`.
|
||||
|
||||
+102
@@ -26,6 +26,108 @@ APIs change between minors without a deprecation period.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **Pictures, 3D models, vision and workflows as MCP tools** at `/mcp`,
|
||||
beside the memory notes, each offered only where the gateway has a model
|
||||
for it: `generate_image` (with `concept`, `side` and `texture` styles),
|
||||
`generate_mesh` (from a picture or a prompt), `review_images` (a vision
|
||||
model, to a JSON schema), `run_workflow`, `job_status` and
|
||||
`gateway_status`. Each makes its request under the caller's key, so
|
||||
allowlists, limits, licences and budgets apply and it is charged like
|
||||
any request; what it makes is kept as the caller's file and answered by
|
||||
id and download URL. A client that takes a stream gets
|
||||
`notifications/progress` while a mesh is made and waits up to ten
|
||||
minutes; one that does not, 50 seconds, then the job id.
|
||||
|
||||
- **Extended thinking on `/v1/messages`**: `thinking: {type: "enabled"}`
|
||||
tells a model that thinks by its chat template to (`enable_thinking`),
|
||||
and its reasoning comes back as `thinking` blocks before the text,
|
||||
streamed as `thinking_delta` events; `disabled` tells it not to. Not
|
||||
asked for, the reasoning is left out as before.
|
||||
- **How long on-demand models hold a card** is a setting, in force on the
|
||||
leader within seconds: `on_demand.evict_after_idle` (2m unless set) and
|
||||
`on_demand.min_lifetime` (5m). On one card shared by a picture model and
|
||||
a 3D model, 1m each brings the wait between a picture and its mesh to
|
||||
about a minute.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Jobs ran one at a time, so a 3D job waiting for the card held up another
|
||||
model's job that could have run. They run side by side, one per model at
|
||||
a time and eight in all, on the leader as before.
|
||||
- `llmgw ctl model serve` served an embedding model as a chat model: no
|
||||
`--embedding`, four slots, a 512-token batch, `embeddings` unset and a
|
||||
"say hello" smoke test it could not pass. A model whose GGUF says it
|
||||
pools (or `--embeddings`) is served for embedding: 32 slots of 1024
|
||||
tokens unless sized otherwise, a batch that holds a whole passage, and
|
||||
one vector asked for as its smoke test.
|
||||
- `llmgw ctl upgrade` checks every node's version once the waves are done
|
||||
and fails naming any not on the release, and says when the control plane
|
||||
has no nodes registered; `llmgw upgrade` on a control plane says how many
|
||||
nodes it left behind and the command that upgrades them. A site's nodes
|
||||
stayed a release behind with nothing to say so.
|
||||
- The cluster and node pages swapped their live part every few seconds,
|
||||
forms and all, so filling one in raced the clock: what was typed, an
|
||||
open choice and the focus were lost. The refresh now waits while a field
|
||||
in it has the focus or a form in it has been typed into and not sent;
|
||||
the mirror page's reload during a download waits the same way.
|
||||
|
||||
## v0.14.5 — 2026-10-14
|
||||
|
||||
Picture and 3D models that add themselves where a card fits them, with a
|
||||
card on the portal and a screen in the console to see and add them; a
|
||||
mesh's colour brought to the picture it was made from; and the whole
|
||||
licence written into every mesh.
|
||||
|
||||
**Upgrading.**
|
||||
- The store gains a table (migration 0047: preset_state); nothing to do.
|
||||
- **`presets.auto` is on**: once upgraded, the leading control plane adds
|
||||
flux-schnell and triposr, on demand, where a node's card fits one, the
|
||||
cluster installs ComfyUI (`engines.comfyui`) and the catalog makes
|
||||
nothing of that kind. Nothing is fetched until somebody asks for one.
|
||||
Turn it off with `llmgw ctl model presets auto off`.
|
||||
- **Nodes** need this release for `albedo_match` (it is in the save node
|
||||
the gate puts in each replica); a ComfyUI replica takes it when it
|
||||
starts again.
|
||||
|
||||
### Added
|
||||
|
||||
- **A mesh's base colour brought to its picture's**: `LLMGWSaveMesh` takes
|
||||
`albedo_match` with the picture and its mask, and scales the texture in
|
||||
linear light to the object's brightness in the picture. Stable Fast 3D
|
||||
takes the light out of what it sees and read a dark basalt (0.17) as a
|
||||
light stone dimly lit (0.67); matched, it is 0.17. On sf3d it is the
|
||||
`albedo_match` input (off unless asked).
|
||||
- **Picture and 3D models by themselves**: once a node has a card that fits
|
||||
flux-schnell or triposr and the catalog makes nothing of that kind, the
|
||||
leading control plane adds the preset on demand — so a new cluster's
|
||||
portal has its Images and 3D pages without a definition written — where
|
||||
the cluster installs ComfyUI. `presets.auto` turns it off; a preset
|
||||
somebody removes is not added back. `GET /api/v1/presets` says how each
|
||||
stands and why one is not added; `POST /api/v1/presets/{name}` adds one
|
||||
(and sets `engines.comfyui` if the cluster has none); `PUT
|
||||
/api/v1/presets/auto`. Each automatic addition is in the audit log.
|
||||
- **Presets on the portal and in the console**: the cluster page's
|
||||
**Pictures and 3D models** card lists each preset — what it makes, the
|
||||
classes it fits here, whether and by whom it was added, why the leader
|
||||
has not — with an **Add** button that says first when it would install
|
||||
ComfyUI and restart the control planes, and the switch for `presets.auto`.
|
||||
The console's models screen shows the same; **A** adds one, **P** flips
|
||||
the switch. `ctl model add PRESET` and `ctl model presets` go through the
|
||||
API (and `ctl model presets auto on|off`), falling back to the entry as
|
||||
v0.14.4 sent it on an older control plane.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A mesh carried only its licence's name, URL and notice; it carries the
|
||||
whole licence as `/v1/models` gives it — id, commercial use, excluded
|
||||
regions, revenue cap, attribution — for a pipeline that sorts assets by
|
||||
licence.
|
||||
- A workflow's `{{name|false}}` default filled a BOOLEAN input with the
|
||||
word rather than a boolean.
|
||||
|
||||
|
||||
## v0.14.4 — 2026-10-13
|
||||
|
||||
Any ComfyUI workflow as a job, with typed inputs; picture and 3D models
|
||||
|
||||
@@ -40,7 +40,7 @@ Releases track the roadmap phases; see [CHANGELOG.md](CHANGELOG.md).
|
||||
| v0.11.0 | — | High availability from setup, the setup and console screens, usage sums and per-tenant alerts | released |
|
||||
| v0.12.0 | — | The cluster's settings in the store, in force when changed; the firewall switch; wipe | released |
|
||||
| v0.13.0 | — | The whole system stopped and started from one command; Qwen3Guard judges, on a GPU and CPUs together | released |
|
||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences) |
|
||||
| v0.14.0 | — | Knowledge bases: documents from people's own machines, kept apart per tenant, answered from | released (v0.14.1: adapters, the tree over the API, the firewall on every machine; v0.14.2: pictures from ComfyUI; v0.14.3: 3D models as jobs; v0.14.4: any ComfyUI workflow as a job, presets, licences; v0.14.5: picture and 3D models that add themselves) |
|
||||
| v1.0.0 | 5 | What phase 5 has left: precise KV routing proven on vLLM, the block cost picker, defragmentation across NVLink domains, prefill/decode disaggregation | planned |
|
||||
|
||||
As of v0.6.0 a cluster runs unattended: agents report their hardware, the
|
||||
@@ -50,6 +50,13 @@ 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)):
|
||||
|
||||
- **Picture and 3D models that add themselves** (v0.14.5). Where a node's
|
||||
card fits flux-schnell or triposr and the catalog makes nothing of that
|
||||
kind, the leader adds it on demand, so a new cluster's portal has its
|
||||
Images and 3D pages; the cluster page and the console show each preset
|
||||
and add it by hand, and `presets.auto` turns it off. Stable Fast 3D's
|
||||
colours can be brought to the picture's (`albedo_match`), and every mesh
|
||||
carries its whole licence.
|
||||
- **Any ComfyUI workflow, and models by name** (v0.14.4). `POST
|
||||
/v1/workflows/runs` runs a ComfyUI model's workflow as a job, its inputs
|
||||
(text, numbers, choices, images, masks, audio, video, files) declared in
|
||||
|
||||
@@ -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",
|
||||
@@ -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.",
|
||||
@@ -10221,6 +10413,10 @@
|
||||
"type": "boolean",
|
||||
"description": "Tools says the model makes tool calls the engine returns as calls: it is written into the catalog, where clients and editors read it. Check a running replica with llmgw ctl tool-check before saying so."
|
||||
},
|
||||
"embeddings": {
|
||||
"type": "boolean",
|
||||
"description": "Embeddings serves it as an embedding model, which its GGUF header says by itself for most: --embedding, many slots, a batch that holds a whole passage, /v1/embeddings."
|
||||
},
|
||||
"cpu_cores": {
|
||||
"type": "integer",
|
||||
"description": "CPUCores is what a replica computes on; zero takes half the smallest node it fits on."
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
"/mcp": {
|
||||
"post": {
|
||||
"operationId": "postMcp",
|
||||
"summary": "The gateway's MCP server: the memory notes as tools",
|
||||
"summary": "The gateway's MCP server: the memory notes, and pictures, 3D models, vision and workflows, as tools",
|
||||
"tags": [
|
||||
"mcp"
|
||||
],
|
||||
|
||||
@@ -534,6 +534,42 @@ curl https://llm.example.com/mcp \
|
||||
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
|
||||
```
|
||||
|
||||
**Pictures, 3D models, vision and workflows.** Where the gateway has the
|
||||
models for them, the same server offers more tools — only those it has a
|
||||
model for:
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `generate_image` | A picture from a prompt. `style` adds what a picture for a mesh wants: `concept` (one object, three-quarter view, grey background), `side` (an exact side view), `texture` (a seamless tile) or `none`. Answers the PNG's `file_id`, `url` and `seed`, with a 512 px preview to look at. |
|
||||
| `generate_mesh` | A GLB from a picture — `image` is a `file-…` id (a `generate_image` result, say), a data URL or base64 — or from a `prompt`, for which a concept picture is made first. `seed`, `resolution`, `texture_resolution`, and any other input the model's workflow declares in `inputs`. Answers the GLB's `file_id`, `url` and licence. |
|
||||
| `review_images` | Asks a vision model about one to four pictures (each shrunk to 1280 px on its long side); `json_schema` makes it answer that JSON. |
|
||||
| `run_workflow` | Runs a ComfyUI workflow from the inputs its catalog entry declares, and answers its result file. |
|
||||
| `job_status` | Where a mesh or a workflow run stands, and its file when done; `wait_seconds` waits for it. |
|
||||
| `gateway_status` | The picture, 3D, vision and workflow models, whether each is loaded on a GPU now, a workflow's inputs, and your last jobs. |
|
||||
|
||||
Each tool makes its request of the gateway under your key, so your
|
||||
allowlist, limits, left-out licences and budget apply, and it is charged
|
||||
and listed like any other request. What it makes is kept as your file for
|
||||
as long as job results are: fetch it from `url` with the same key (`curl -H
|
||||
"Authorization: Bearer $LLMGW_API_KEY" -o rock.glb <url>`). A tool server
|
||||
over HTTP cannot write to your disk, so an agent that needs the file
|
||||
locally — to import it in Blender — fetches it, or goes through a small
|
||||
local bridge that does.
|
||||
|
||||
A mesh takes from half a minute to a few minutes. A client that takes the
|
||||
answer as a stream (`Accept: text/event-stream`, as Claude Code does) is
|
||||
answered that way: `notifications/progress` for the call's
|
||||
`progressToken` while it runs — queued, waiting for the GPU, the node and
|
||||
its percent — then the result, and the call waits up to ten minutes. One
|
||||
answered in one piece waits 50 seconds; a job not done by then is answered
|
||||
with its `job_id`, to ask after with `job_status`. `wait_seconds` (up to
|
||||
900) sets either.
|
||||
|
||||
```
|
||||
claude mcp add --transport http llmgw https://llm.example.com/mcp \
|
||||
--header "Authorization: Bearer $LLMGW_API_KEY"
|
||||
```
|
||||
|
||||
### Licences you leave out
|
||||
|
||||
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
|
||||
|
||||
+17
-2
@@ -1313,8 +1313,23 @@ 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. In the console it is **A** on the models screen.
|
||||
The rest of this section is for a model of your own.
|
||||
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:
|
||||
|
||||
@@ -205,6 +205,8 @@ rather than repeated.
|
||||
| `no_passwords` | `bool` | [OIDC](#oidc) |
|
||||
| `mirror` | `string` | [Offline](#offline) |
|
||||
| `checksums` | `string` | [Offline](#offline) |
|
||||
| `evict_after_idle` | `time.Duration` | [OnDemandSettings](#ondemandsettings) |
|
||||
| `min_lifetime` | `time.Duration` | [OnDemandSettings](#ondemandsettings) |
|
||||
| `nvidia_smi` | `string` | [Options](#options) |
|
||||
| `ib_root` | `string` | [Options](#options) |
|
||||
| `prefer_nvml` | `bool` | [Options](#options) |
|
||||
@@ -244,6 +246,7 @@ rather than repeated.
|
||||
| `match_ratio` | `float64` | [Prefix](#prefix) |
|
||||
| `balance_abs` | `float64` | [Prefix](#prefix) |
|
||||
| `balance_rel` | `float64` | [Prefix](#prefix) |
|
||||
| `auto` | `bool` | [PresetSettings](#presetsettings) |
|
||||
| `source` | `string` | [PreviousOrigin](#previousorigin) |
|
||||
| `revision` | `string` | [PreviousOrigin](#previousorigin) |
|
||||
| `files` | `[]OriginFile` | [PreviousOrigin](#previousorigin) |
|
||||
@@ -344,6 +347,8 @@ rather than repeated.
|
||||
| `mail` | `mail.Config` | [Server](#server) |
|
||||
| `serving` | `ServingSettings` | [Server](#server) |
|
||||
| `firewall` | `FirewallSettings` | [Server](#server) |
|
||||
| `presets` | `PresetSettings` | [Server](#server) |
|
||||
| `on_demand` | `OnDemandSettings` | [Server](#server) |
|
||||
| `agents` | `AgentSettings` | [Server](#server) |
|
||||
| `firewall_networks` | `[]string` | [Server](#server) |
|
||||
| `backup_recipients` | `[]string` | [Server](#server) |
|
||||
@@ -1910,6 +1915,25 @@ kept by llmgw bundle import and llmgw bundle trust; every engine file
|
||||
a node fetches from the mirror is checked against it. Read again
|
||||
whenever it changes. Empty is /etc/llmgw/offline.sha256.
|
||||
|
||||
## OnDemandSettings
|
||||
|
||||
### `evict_after_idle`
|
||||
|
||||
`time.Duration`
|
||||
|
||||
EvictAfterIdle is how long an on-demand replica must go unasked-for
|
||||
before a model that wants its card may have it. Zero keeps 2m.
|
||||
|
||||
### `min_lifetime`
|
||||
|
||||
`time.Duration`
|
||||
|
||||
MinLifetime is how long a replica that has just come up is safe from
|
||||
that, so two models do not take turns paying for loads. Zero keeps
|
||||
5m. On one card shared by a picture model and a 3D model, 1m each
|
||||
brings the wait between a picture and its mesh to about a minute.
|
||||
Both are in force on the leader within seconds.
|
||||
|
||||
## Options
|
||||
|
||||
### `nvidia_smi`
|
||||
@@ -2158,6 +2182,20 @@ before affinity is abandoned.
|
||||
|
||||
`float64`
|
||||
|
||||
## PresetSettings
|
||||
|
||||
### `auto`
|
||||
|
||||
`bool`
|
||||
|
||||
Auto has the leading control plane add a picture model (flux-schnell)
|
||||
and a 3D model (triposr) by itself, on demand, once a node has a card
|
||||
that fits one and the catalog has no model that makes the same kind of
|
||||
thing — so a new cluster's portal has its pictures and 3D pages
|
||||
without anybody writing a definition. Only where the cluster already
|
||||
installs ComfyUI (engines.comfyui). A preset somebody removed is not
|
||||
added again by itself. In force on the leader within a minute.
|
||||
|
||||
## PreviousOrigin
|
||||
|
||||
### `source`
|
||||
@@ -2960,6 +2998,23 @@ Firewall turns llmgw's firewall on every machine on or off.
|
||||
|
||||
Takes a [FirewallSettings](#firewallsettings) block.
|
||||
|
||||
### `presets`
|
||||
|
||||
`PresetSettings`
|
||||
|
||||
Presets is how the models that come with llmgw reach the catalog.
|
||||
|
||||
Takes a [PresetSettings](#presetsettings) block.
|
||||
|
||||
### `on_demand`
|
||||
|
||||
`OnDemandSettings`
|
||||
|
||||
OnDemand is how models that load when asked for take turns on the
|
||||
cards.
|
||||
|
||||
Takes a [OnDemandSettings](#ondemandsettings) block.
|
||||
|
||||
### `agents`
|
||||
|
||||
`AgentSettings`
|
||||
|
||||
@@ -243,6 +243,10 @@ var liveSettings = []string{
|
||||
"firewall", "firewall_networks",
|
||||
// Sent to the nodes' agents as they change.
|
||||
"agents",
|
||||
// Read by the leader's presets pass each time it runs.
|
||||
"presets",
|
||||
// Handed to the reconciler as they change.
|
||||
"on_demand",
|
||||
// Read by server init, node add and backups, not by the server.
|
||||
"backup_recipients", "backup_copy_to",
|
||||
}
|
||||
|
||||
@@ -268,6 +268,26 @@ func upgradeNodes(ctx context.Context, out io.Writer, client func() *ctlClient,
|
||||
}
|
||||
fmt.Fprintf(out, "%d node(s) upgraded to %s in %s, %d already on it\n",
|
||||
done, v, time.Since(start).Round(time.Second), skipped)
|
||||
// Checked at the end, against what the control plane says now: a node
|
||||
// that is not on the version is said by name, and the command fails. A
|
||||
// site's nodes stayed a release behind with nothing printed to say so.
|
||||
if len(nodes) == 0 {
|
||||
fmt.Fprintln(out, "This control plane has no nodes registered, so none were upgraded.")
|
||||
}
|
||||
var now []adminapi.NodeView
|
||||
if err := client().get(ctx, "/api/v1/nodes", &now); err == nil {
|
||||
var behind []string
|
||||
for _, n := range now {
|
||||
if (len(only) == 0 || slices.Contains(only, n.ID)) && n.AgentVersion != v {
|
||||
behind = append(behind, fmt.Sprintf("%s (%s, %s)", n.ID, orUnknown(n.AgentVersion), n.State))
|
||||
}
|
||||
}
|
||||
if len(behind) > 0 {
|
||||
slices.Sort(behind)
|
||||
return fmt.Errorf("not on %s: %s\nrun this again to try them, or `llmgw ctl logs NODE` for why",
|
||||
v, strings.Join(behind, ", "))
|
||||
}
|
||||
}
|
||||
// The one machine this does not reach. Said every time, because it is
|
||||
// exactly the kind of thing that stays three versions behind unnoticed.
|
||||
fmt.Fprintf(out, "The model mirror's host is not upgraded by this; on it, run:\n"+
|
||||
|
||||
@@ -150,3 +150,54 @@ func TestABinaryInstalledByHandIsNotAServerRunningIt(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpgradeThatFindsNoNodesSaysSo(t *testing.T) {
|
||||
// A site's nodes stayed a release behind with nothing printed to say
|
||||
// that none had been reached.
|
||||
f := &fakeFleet{versions: map[string]string{}}
|
||||
srv := f.server(t)
|
||||
var out bytes.Buffer
|
||||
if err := upgradeNodes(t.Context(), &out, func() *ctlClient { return newCtlClient(srv.URL) }, "v1.0.1", nil, time.Second, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "no nodes registered") {
|
||||
t.Errorf("output:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUpgradeFailsNamingANodeLeftBehind(t *testing.T) {
|
||||
// A node that joins, or comes back on its old binary, while the waves
|
||||
// run is found at the end rather than passed over.
|
||||
f := &fakeFleet{versions: map[string]string{"a": "v1.0.0"}}
|
||||
srv := f.server(t)
|
||||
late := func() *ctlClient {
|
||||
f.mu.Lock()
|
||||
if len(f.told) == 1 {
|
||||
f.versions["late"] = "v1.0.0"
|
||||
}
|
||||
f.mu.Unlock()
|
||||
return newCtlClient(srv.URL)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
err := upgradeNodes(t.Context(), &out, late, "v1.0.1", nil, 5*time.Second, 1)
|
||||
if err == nil || !strings.Contains(err.Error(), "late (v1.0.0, ready)") {
|
||||
t.Errorf("err %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpgradingAControlPlaneAloneSaysItsNodesWereNot(t *testing.T) {
|
||||
f := &fakeFleet{versions: map[string]string{"p-g1": "v1.0.0", "p-g3": "v1.0.1"}}
|
||||
srv := f.server(t)
|
||||
var out bytes.Buffer
|
||||
nodesLeftBehind(t.Context(), &out, newCtlClient(srv.URL), "v1.0.1")
|
||||
if !strings.Contains(out.String(), "1 of 2 node(s) are not on v1.0.1") ||
|
||||
!strings.Contains(out.String(), "ctl upgrade v1.0.1 --skip-control-plane") {
|
||||
t.Errorf("output:\n%s", out.String())
|
||||
}
|
||||
out.Reset()
|
||||
f.versions["p-g1"] = "v1.0.1"
|
||||
nodesLeftBehind(t.Context(), &out, newCtlClient(srv.URL), "v1.0.1")
|
||||
if out.Len() != 0 {
|
||||
t.Errorf("said something with every node on the version: %s", out.String())
|
||||
}
|
||||
}
|
||||
+16
-1
@@ -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), newModelPresetsCmd(), 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),
|
||||
@@ -73,6 +74,20 @@ func newModelAddCmd(client func() *ctlClient) *cobra.Command {
|
||||
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",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -11,25 +13,106 @@ import (
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||
)
|
||||
|
||||
func newModelPresetsCmd() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
func newModelPresetsCmd(client func() *ctlClient, asJSON *bool) *cobra.Command {
|
||||
c := &cobra.Command{
|
||||
Use: "presets",
|
||||
Short: "The models that come with llmgw, added by name",
|
||||
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`.",
|
||||
"`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 {
|
||||
w := tabTo(cmd.OutOrStdout())
|
||||
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)
|
||||
var v adminapi.PresetsView
|
||||
err := client().get(cmd.Context(), "/api/v1/presets", &v)
|
||||
if err != nil && !olderControlPlane(err) {
|
||||
return err
|
||||
}
|
||||
return w.Flush()
|
||||
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 {
|
||||
|
||||
@@ -17,6 +17,10 @@ import (
|
||||
// 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
|
||||
@@ -30,6 +34,10 @@ func (a *presetAdmin) client(t *testing.T) func() *ctlClient {
|
||||
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)
|
||||
@@ -62,6 +70,19 @@ func runModelAdd(t *testing.T, client func() *ctlClient, args ...string) (string
|
||||
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")
|
||||
|
||||
@@ -60,6 +60,8 @@ func newModelServeCmd(client func() *ctlClient) *cobra.Command {
|
||||
f.StringVar(&req.Quant, "quant", "", "the quantisation to serve (default: the one that suits the cluster best)")
|
||||
f.StringVar(&req.File, "file", "", "the file to serve, instead of choosing by --quant")
|
||||
f.StringVar(&req.Projector, "projector", "", "a vision model's mmproj file, fetched and loaded beside it")
|
||||
f.BoolVar(&req.Embeddings, "embeddings", false, "serve it as an embedding model (its GGUF header says so for most): "+
|
||||
"--embedding, 32 slots of 1024 tokens unless --ctx-pool or --context-length says otherwise")
|
||||
f.StringVar(&req.Format, "format", "gguf", "gguf, run by llama.cpp, or safetensors: the original weights, run by vLLM on GPUs")
|
||||
f.BoolVar(&req.CPU, "cpu", false, "run it on the CPU, whatever the plan says")
|
||||
f.StringVar(&req.GPUClass, "gpu-class", "", "run it on GPUs of this class, whatever the plan says")
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
@@ -70,6 +73,9 @@ func newSelfUpgradeCmd() *cobra.Command {
|
||||
if strings.TrimSpace(string(b)) == "active" {
|
||||
if steady++; steady >= 3 {
|
||||
fmt.Fprintf(out, "%s restarted and running\n", service)
|
||||
if service == "llmgw-server" {
|
||||
nodesLeftBehind(cmd.Context(), out, newCtlClient("127.0.0.1:9090"), v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
@@ -85,3 +91,25 @@ func newSelfUpgradeCmd() *cobra.Command {
|
||||
c.Flags().StringVar(&service, "service", "", "the systemd unit to restart afterwards, e.g. llmgw-mirror")
|
||||
return c
|
||||
}
|
||||
|
||||
// nodesLeftBehind says, after a control plane alone was upgraded, that its
|
||||
// nodes were not, and how they are: this command looks like the cluster's
|
||||
// upgrade and is not, and a site's nodes stayed a release behind after it.
|
||||
func nodesLeftBehind(ctx context.Context, out io.Writer, c *ctlClient, v string) {
|
||||
var nodes []adminapi.NodeView
|
||||
_ = c.patient(20*time.Second).get(ctx, "/api/v1/nodes", &nodes)
|
||||
behind := 0
|
||||
for _, n := range nodes {
|
||||
if n.AgentVersion != v {
|
||||
behind++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(nodes) == 0:
|
||||
fmt.Fprintf(out, "This upgraded this machine only. The nodes are upgraded from the leading "+
|
||||
"control plane with:\n sudo llmgw ctl upgrade %s --skip-control-plane\n", v)
|
||||
case behind > 0:
|
||||
fmt.Fprintf(out, "This upgraded this machine only: %d of %d node(s) are not on %s. On the leading "+
|
||||
"control plane, run:\n sudo llmgw ctl upgrade %s --skip-control-plane\n", behind, len(nodes), v, v)
|
||||
}
|
||||
}
|
||||
@@ -264,6 +264,7 @@ func newServerCmd() *cobra.Command {
|
||||
agentSvc = svc
|
||||
// Stopped by the operators (llmgw stop): nothing placed.
|
||||
ctl.Reconciler().SetStopped(cfg.Serving.Stopped)
|
||||
ctl.Reconciler().SetEvictionTimes(cfg.OnDemand.EvictAfterIdle, cfg.OnDemand.MinLifetime)
|
||||
// Whether nodes keep llmgw's firewall, as the settings say now.
|
||||
svc.SetFirewall(func() *llmgwv1.Firewall {
|
||||
c := current.Load()
|
||||
@@ -465,6 +466,14 @@ func newServerCmd() *cobra.Command {
|
||||
}
|
||||
api.SetModels(svc)
|
||||
catalogSvc = svc
|
||||
// The models that come with llmgw: added by the leader
|
||||
// when a node fits one and the catalog makes nothing of
|
||||
// its kind, unless somebody removed it or turned it off.
|
||||
api.SetPresets(st, func() adminapi.PresetConfig {
|
||||
c := current.Load()
|
||||
return adminapi.PresetConfig{Auto: c.Presets.Auto, ComfyUI: c.Engines[catalog.EngineComfyUI]}
|
||||
})
|
||||
go runPresetsPass(ctx, api, admin.Leadership(), log)
|
||||
// And the mirror page, so a model that has just been
|
||||
// fetched can be served without writing YAML by hand.
|
||||
rt.SetCatalogAdmin(svc)
|
||||
@@ -770,6 +779,9 @@ func newServerCmd() *cobra.Command {
|
||||
fwPub.Kick()
|
||||
}
|
||||
}
|
||||
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "on_demand") }) && admin != nil {
|
||||
admin.Reconciler().SetEvictionTimes(next.OnDemand.EvictAfterIdle, next.OnDemand.MinLifetime)
|
||||
}
|
||||
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "agents") }) && agentSvc != nil {
|
||||
// A moment apart per node, so off this goroutine.
|
||||
go agentSvc.BroadcastAgentSettings()
|
||||
@@ -1281,3 +1293,32 @@ func embedder(replay func(ctx context.Context, path, model string, body []byte)
|
||||
return res.Data[0].Embedding, nil
|
||||
}
|
||||
}
|
||||
|
||||
// presetsEvery is how often the leader looks for presets to add; the first
|
||||
// look waits as long, for the nodes to have registered their cards.
|
||||
const presetsEvery = time.Minute
|
||||
|
||||
// runPresetsPass has the leader add the presets a node fits and the catalog
|
||||
// lacks, now and then.
|
||||
func runPresetsPass(ctx context.Context, api *adminapi.API, lead *control.Leadership, log *slog.Logger) {
|
||||
t := time.NewTicker(presetsEvery)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
if lead != nil && !lead.IsLeader() {
|
||||
continue
|
||||
}
|
||||
added, err := api.PresetsPass(ctx)
|
||||
for _, name := range added {
|
||||
log.Info("added a preset by itself: a node fits it and the catalog made nothing of its kind",
|
||||
"model", name, "off_switch", "presets.auto")
|
||||
}
|
||||
if err != nil {
|
||||
log.Warn("could not add a preset", "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
@@ -1238,8 +1249,12 @@ func (m consoleModel) panel() (title, body, keys string) {
|
||||
}
|
||||
if m.inPanel {
|
||||
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 {
|
||||
|
||||
@@ -269,15 +269,20 @@ func (m *consoleModel) addModelForm() {
|
||||
}
|
||||
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: note.String(),
|
||||
fields: []formField{
|
||||
choiceF("model", append(catalog.PresetNames(), fromFile), catalog.PresetNames()[0]),
|
||||
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 v[0] != fromFile {
|
||||
return m.runCLI("Add "+v[0], "ctl", server, "model", "add", v[0]), nil
|
||||
// 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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -107,7 +107,7 @@ var Routes = []Route{
|
||||
{Method: "GET", Path: "/v1/memory/{key}", Summary: "A note", Response: MemoryNote{}},
|
||||
{Method: "GET", Path: "/v1/memory", Summary: "The project's notes", Response: MemoryList{}},
|
||||
{Method: "DELETE", Path: "/v1/memory/{key}", Summary: "Forget a note"},
|
||||
{Method: "POST", Path: "/mcp", Summary: "The gateway's MCP server: the memory notes as tools",
|
||||
{Method: "POST", Path: "/mcp", Summary: "The gateway's MCP server: the memory notes, and pictures, 3D models, vision and workflows, as tools",
|
||||
Request: MCPRequest{}, Response: anyJSON},
|
||||
{Method: "GET", Path: "/me", Summary: "The caller: its key, its place in the tree, its limits and what it has used",
|
||||
Response: Me{}},
|
||||
|
||||
@@ -222,6 +222,10 @@ func fillString(s string, p Params, missing *[]string) any {
|
||||
if f, err := strconv.ParseFloat(def, 64); err == nil {
|
||||
return f, true
|
||||
}
|
||||
// A BOOLEAN input refuses the word: {{albedo_match|false}}.
|
||||
if b, err := strconv.ParseBool(def); err == nil && (def == "true" || def == "false") {
|
||||
return b, true
|
||||
}
|
||||
return def, true
|
||||
}
|
||||
*missing = append(*missing, name)
|
||||
|
||||
@@ -252,3 +252,19 @@ func TestAPictureLosesTheWorkflowComfyUIWroteIntoIt(t *testing.T) {
|
||||
t.Error("a truncated PNG was changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestATrueOrFalseDefaultIsABoolean(t *testing.T) {
|
||||
// A BOOLEAN input refuses the word "false": sf3d's albedo_match.
|
||||
tpl, err := Load((`{"5":{"class_type":"LLMGWSaveMesh","inputs":{"albedo_match":"{{albedo_match|false}}","count":"{{count|2}}","s":"{{s|x}}"}}}`))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g, err := tpl.Fill(Params{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
in := g["5"].(map[string]any)["inputs"].(map[string]any)
|
||||
if in["albedo_match"] != false || in["count"] != 2.0 || in["s"] != "x" {
|
||||
t.Errorf("inputs %#v", in)
|
||||
}
|
||||
}
|
||||
@@ -94,22 +94,60 @@ class LLMGWPrepareForMesh:
|
||||
return (both[..., :3].contiguous(), both[..., 3].contiguous())
|
||||
|
||||
|
||||
def _to_linear(x):
|
||||
return np.where(x <= 0.04045, x / 12.92, ((x + 0.055) / 1.055) ** 2.4)
|
||||
|
||||
|
||||
def _to_srgb(x):
|
||||
return np.where(x <= 0.0031308, x * 12.92, 1.055 * np.power(np.clip(x, 0, None), 1 / 2.4) - 0.055)
|
||||
|
||||
|
||||
def _match_albedo(tex, reference, mask):
|
||||
"""The texture's brightness brought to the picture's: Stable Fast 3D
|
||||
takes the light out of what it sees, and a dark object it reads as a
|
||||
light one dimly lit — a basalt of 0.17 came back 0.67. Scaled in linear
|
||||
light by the ratio of the medians over the object, so its marks keep
|
||||
their contrast; texels nothing maps to (black) are left as they are."""
|
||||
t = np.asarray(tex.convert("RGB")).astype(np.float64) / 255.0
|
||||
used = t.max(-1) > 2 / 255
|
||||
ref = reference.cpu().numpy().astype(np.float64)
|
||||
obj = mask.cpu().numpy() > 0.5 if mask is not None else np.ones(ref.shape[:2], bool)
|
||||
if used.sum() < 16 or obj.sum() < 16:
|
||||
return tex
|
||||
lum = lambda a: a @ np.array([0.2126, 0.7152, 0.0722])
|
||||
want = np.median(lum(_to_linear(ref[obj])))
|
||||
have = np.median(lum(_to_linear(t[used])))
|
||||
if have <= 0:
|
||||
return tex
|
||||
k = float(np.clip(want / have, 0.05, 20.0))
|
||||
out = t.copy()
|
||||
out[used] = np.clip(_to_srgb(_to_linear(t[used]) * k), 0, 1)
|
||||
img = Image.fromarray((out * 255 + 0.5).astype(np.uint8))
|
||||
img.format = tex.format
|
||||
return img
|
||||
|
||||
|
||||
class LLMGWSaveMesh:
|
||||
"""Writes each mesh a 3D node made to output/ as a GLB and names the
|
||||
files, which is how the gate finds a result. A model's own save node
|
||||
may return the mesh as base64 (Stable Fast 3D's does) or as an OBJ
|
||||
without its texture, and the gate reads neither."""
|
||||
without its texture, and the gate reads neither.
|
||||
|
||||
With albedo_match and the picture the mesh was made from (and its
|
||||
mask), the mesh's base colour is brought to the picture's brightness."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"mesh": ("MESH",), "filename_prefix": ("STRING", {"default": "mesh"})}}
|
||||
return {"required": {"mesh": ("MESH",), "filename_prefix": ("STRING", {"default": "mesh"})},
|
||||
"optional": {"albedo_match": ("BOOLEAN", {"default": False}),
|
||||
"reference": ("IMAGE",), "reference_mask": ("MASK",)}}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "save"
|
||||
CATEGORY = "llmgw"
|
||||
|
||||
def save(self, mesh, filename_prefix="mesh"):
|
||||
def save(self, mesh, filename_prefix="mesh", albedo_match=False, reference=None, reference_mask=None):
|
||||
import os
|
||||
import folder_paths
|
||||
import trimesh
|
||||
@@ -119,6 +157,9 @@ class LLMGWSaveMesh:
|
||||
filename_prefix, folder_paths.get_output_directory())
|
||||
saved = []
|
||||
for i, m in enumerate(mesh if isinstance(mesh, (list, tuple)) else [mesh]):
|
||||
if albedo_match and reference is not None:
|
||||
_match_scene(m, reference[min(i, reference.shape[0] - 1)],
|
||||
None if reference_mask is None else reference_mask[min(i, reference_mask.shape[0] - 1)])
|
||||
file = f"{name.replace('%batch_num%', str(i))}_{counter + i:05}_.glb"
|
||||
scene = m if isinstance(m, trimesh.Scene) else trimesh.Scene(m)
|
||||
with open(os.path.join(folder, file), "wb") as f:
|
||||
@@ -127,6 +168,16 @@ class LLMGWSaveMesh:
|
||||
return {"ui": {"mesh": saved}}
|
||||
|
||||
|
||||
def _match_scene(m, reference, mask):
|
||||
"""_match_albedo on every base colour texture of a mesh or scene."""
|
||||
geoms = m.geometry.values() if hasattr(m, "geometry") else [m]
|
||||
for g in geoms:
|
||||
mat = getattr(getattr(g, "visual", None), "material", None)
|
||||
tex = getattr(mat, "baseColorTexture", None)
|
||||
if tex is not None:
|
||||
mat.baseColorTexture = _match_albedo(tex, reference, mask)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {"LLMGWPrepareForMesh": LLMGWPrepareForMesh, "LLMGWSaveMesh": LLMGWSaveMesh}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {"LLMGWPrepareForMesh": "Prepare a picture for a 3D model (llmgw)",
|
||||
"LLMGWSaveMesh": "Save a mesh as GLB (llmgw)"}
|
||||
@@ -158,6 +158,13 @@ type Server struct {
|
||||
// Firewall turns llmgw's firewall on every machine on or off.
|
||||
Firewall FirewallSettings `koanf:"firewall"`
|
||||
|
||||
// Presets is how the models that come with llmgw reach the catalog.
|
||||
Presets PresetSettings `koanf:"presets"`
|
||||
|
||||
// OnDemand is how models that load when asked for take turns on the
|
||||
// cards.
|
||||
OnDemand OnDemandSettings `koanf:"on_demand"`
|
||||
|
||||
// Agents are the nodes' agent settings: agent.yaml's keys but those an
|
||||
// agent needs to start, for every node (default) and for one
|
||||
// (nodes.<id>). Sent to the agents as they change; an agent whose
|
||||
@@ -795,6 +802,7 @@ func Default() Server {
|
||||
MaxConnections: 10_000,
|
||||
Mirror: Mirror{UpdateCheck: 6 * time.Hour},
|
||||
Firewall: FirewallSettings{Enabled: true},
|
||||
Presets: PresetSettings{Auto: true},
|
||||
Control: Control{
|
||||
Interval: 5 * time.Second,
|
||||
BaseBackoff: 10 * time.Second,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package config
|
||||
|
||||
import "time"
|
||||
|
||||
// OnDemandSettings is how models that load when asked for take turns on the
|
||||
// cards: on a site with fewer cards than such models, the wait between one
|
||||
// and the next.
|
||||
type OnDemandSettings struct {
|
||||
// EvictAfterIdle is how long an on-demand replica must go unasked-for
|
||||
// before a model that wants its card may have it. Zero keeps 2m.
|
||||
EvictAfterIdle time.Duration `koanf:"evict_after_idle"`
|
||||
// MinLifetime is how long a replica that has just come up is safe from
|
||||
// that, so two models do not take turns paying for loads. Zero keeps
|
||||
// 5m. On one card shared by a picture model and a 3D model, 1m each
|
||||
// brings the wait between a picture and its mesh to about a minute.
|
||||
// Both are in force on the leader within seconds.
|
||||
MinLifetime time.Duration `koanf:"min_lifetime"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -65,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.",
|
||||
@@ -136,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",
|
||||
|
||||
@@ -92,7 +92,7 @@ func (s *Server) addCandidate(ctx context.Context, j *Job, p prepared, cn catalo
|
||||
s.step(j, "smoke test", Running, "asking "+r.ID+" to say hello")
|
||||
var reply string
|
||||
var tps float64
|
||||
if reply, tps, err = s.smoke(ctx, r, cand); err == nil {
|
||||
if reply, tps, err = s.smoke(ctx, r, cand, s.embeds(cand)); err == nil {
|
||||
s.step(j, "smoke test", Done, fmt.Sprintf("answered at %.1f tokens/s: %q", tps, reply))
|
||||
} else {
|
||||
s.step(j, "smoke test", Failed, err.Error())
|
||||
|
||||
@@ -153,6 +153,13 @@ type Request struct {
|
||||
// is written into the catalog, where clients and editors read it. Check
|
||||
// a running replica with llmgw ctl tool-check before saying so.
|
||||
Tools bool `json:"tools,omitempty"`
|
||||
// Embeddings serves it as an embedding model, which its GGUF header
|
||||
// says by itself for most: --embedding, many slots, a batch that holds
|
||||
// a whole passage, /v1/embeddings.
|
||||
Embeddings bool `json:"embeddings,omitempty"`
|
||||
// sized is the caller having said --ctx-pool or --context-length, which
|
||||
// an embedding model's defaults then leave alone.
|
||||
sized bool
|
||||
// CPUCores is what a replica computes on; zero takes half the smallest
|
||||
// node it fits on.
|
||||
CPUCores int `json:"cpu_cores,omitempty"`
|
||||
@@ -268,6 +275,7 @@ func (s *Server) Start(req Request) (Job, error) {
|
||||
if req.GuardFormat != "" && req.Replicas == 0 {
|
||||
return Job{}, errors.New("a judge model must always run: give it --replicas 1 or more, not --on-demand")
|
||||
}
|
||||
req.sized = req.CtxPool > 0 || req.ContextLength > 0
|
||||
if req.CtxPool <= 0 {
|
||||
req.CtxPool = 16384
|
||||
}
|
||||
@@ -473,6 +481,16 @@ func (s *Server) run(j *Job) {
|
||||
}
|
||||
header = v.Header
|
||||
}
|
||||
if header.Embedding {
|
||||
req.Embeddings = true
|
||||
}
|
||||
if req.Embeddings && !req.sized {
|
||||
// Many passages at once rather than four: an import of a hundred
|
||||
// thousand chunks went at the pace of four slots with the card a
|
||||
// third busy.
|
||||
req.ContextLength, req.CtxPool = embedSlotTokens, embedSlotTokens*embedSlots
|
||||
}
|
||||
j.Request = req
|
||||
memGB := int((header.MemoryBytes(set.Bytes, req.CtxPool) + 1<<30 - 1) >> 30)
|
||||
basis := fmt.Sprintf("weights %.1f GB + KV cache %.1f GB for %d tokens + engine",
|
||||
gb(set.Bytes), gb(int64(req.CtxPool)*header.KVBytesPerToken()), req.CtxPool)
|
||||
@@ -587,10 +605,24 @@ func profilesFor(layout []modelplan.Option, req Request, memGB, cores int) []cat
|
||||
// slots of 8192, and the gateway, told 32768, sent requests the engine then
|
||||
// refused.
|
||||
func llamaArgs(req Request) []string {
|
||||
return []string{"--ctx-size", strconv.Itoa(req.CtxPool),
|
||||
args := []string{"--ctx-size", strconv.Itoa(req.CtxPool),
|
||||
"--parallel", strconv.Itoa(max(1, req.CtxPool/req.ContextLength))}
|
||||
if req.Embeddings {
|
||||
// A passage is embedded in one batch: with the default 512, one of
|
||||
// 516 tokens was refused.
|
||||
n := strconv.Itoa(req.ContextLength)
|
||||
args = append([]string{"--embedding"}, append(args, "--batch-size", n, "--ubatch-size", n)...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// An embedding model's slots, when its caller did not size them: passages
|
||||
// of up to a thousand tokens, thirty-two at a time.
|
||||
const (
|
||||
embedSlotTokens = 1024
|
||||
embedSlots = 32
|
||||
)
|
||||
|
||||
// maxOutput is the longest answer the catalog allows: a quarter of the
|
||||
// context, from 1024 to 8192. 1024 everywhere cut an editor's agent off in
|
||||
// the middle of the file it was writing.
|
||||
@@ -620,7 +652,7 @@ func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.F
|
||||
RoutingProfile: "chat", ContextLength: req.ContextLength, MaxOutput: maxOutput(req.ContextLength),
|
||||
Profiles: profs,
|
||||
Scaling: catalog.Scaling{Min: req.Replicas, Max: max(req.Replicas, 1)},
|
||||
Capabilities: catalog.Capabilities{Vision: req.Projector != "", Tools: req.Tools},
|
||||
Capabilities: catalog.Capabilities{Vision: req.Projector != "", Tools: req.Tools, Embeddings: req.Embeddings},
|
||||
Origin: &catalog.Origin{Repo: repo.ID, Revision: repo.Revision, Files: originFiles(set)},
|
||||
}
|
||||
if req.GuardFormat != "" {
|
||||
@@ -655,7 +687,7 @@ func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.F
|
||||
|
||||
// 6. Does it answer.
|
||||
s.step(j, "smoke test", Running, "asking "+rep.ID+" to say hello")
|
||||
reply, tps, err := s.smoke(ctx, rep, name)
|
||||
reply, tps, err := s.smoke(ctx, rep, name, req.Embeddings)
|
||||
if err != nil {
|
||||
s.fail(j, "smoke test", err)
|
||||
return
|
||||
@@ -859,7 +891,10 @@ func (s *Server) waitReady(ctx context.Context, j *Job, name string, hardware bo
|
||||
}
|
||||
|
||||
// smoke sends one short request straight to the replica, with its engine key.
|
||||
func (s *Server) smoke(ctx context.Context, r reconciler.Replica, name string) (string, float64, error) {
|
||||
func (s *Server) smoke(ctx context.Context, r reconciler.Replica, name string, embeddings bool) (string, float64, error) {
|
||||
if embeddings {
|
||||
return s.smokeEmbeddings(ctx, r, name)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"model": name,
|
||||
"max_tokens": 32,
|
||||
@@ -989,3 +1024,50 @@ func pickSafetensors(repo hf.Repo, _ Request) (hf.FileSet, error) {
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// smokeEmbeddings asks an embedding model for one vector, and says its
|
||||
// length: an embedding model does not chat.
|
||||
func (s *Server) smokeEmbeddings(ctx context.Context, r reconciler.Replica, name string) (string, float64, error) {
|
||||
body, _ := json.Marshal(map[string]any{"model": name, "input": "hello"})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
strings.TrimRight(r.Endpoint, "/")+"/v1/embeddings", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if k := s.deps.Fleet.EngineKey(r.ID); k != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+k)
|
||||
}
|
||||
resp, err := s.deps.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", 0, fmt.Errorf("the replica answered %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &out); err != nil || len(out.Data) == 0 || len(out.Data[0].Embedding) == 0 {
|
||||
return "", 0, fmt.Errorf("the replica answered no vector: %s", strings.TrimSpace(string(b)))
|
||||
}
|
||||
return fmt.Sprintf("a vector of %d dimensions", len(out.Data[0].Embedding)), 0, nil
|
||||
}
|
||||
|
||||
// embeds is whether the catalog serves name as an embedding model, for a
|
||||
// smoke test of one already there (a canary, an update).
|
||||
func (s *Server) embeds(name string) bool {
|
||||
if s.deps.Current == nil {
|
||||
return false
|
||||
}
|
||||
for _, m := range s.deps.Current() {
|
||||
if m.Name == name {
|
||||
return m.Capabilities.Embeddings
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -397,3 +397,18 @@ func TestOnlyTheLastFinishedJobsAreRemembered(t *testing.T) {
|
||||
t.Error("the oldest finished job is still remembered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmbeddingModelIsServedForEmbedding(t *testing.T) {
|
||||
// Four slots and a 512-token batch: an import went at four passages a
|
||||
// pass, and one passage of 516 tokens was refused.
|
||||
args := strings.Join(llamaArgs(Request{Embeddings: true, ContextLength: embedSlotTokens,
|
||||
CtxPool: embedSlotTokens * embedSlots}), " ")
|
||||
for _, want := range []string{"--embedding", "--ctx-size 32768", "--parallel 32", "--batch-size 1024", "--ubatch-size 1024"} {
|
||||
if !strings.Contains(args, want) {
|
||||
t.Errorf("%q not in %q", want, args)
|
||||
}
|
||||
}
|
||||
if args := strings.Join(llamaArgs(Request{ContextLength: 4096, CtxPool: 16384}), " "); strings.Contains(args, "embedding") {
|
||||
t.Errorf("a chat model got %q", args)
|
||||
}
|
||||
}
|
||||
@@ -689,7 +689,7 @@ func (s *Server) replace(ctx context.Context, j *Job, name string) error {
|
||||
}
|
||||
s.step(j, "replicas", Done, fmt.Sprintf("%d replaced", len(old)))
|
||||
s.step(j, "smoke test", Running, "asking "+ready[0].ID+" to say hello")
|
||||
reply, tps, err := s.smoke(ctx, ready[0], name)
|
||||
reply, tps, err := s.smoke(ctx, ready[0], name, s.embeds(name))
|
||||
if err != nil {
|
||||
s.step(j, "smoke test", Failed, err.Error())
|
||||
return err
|
||||
|
||||
@@ -120,7 +120,7 @@ func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
|
||||
continue
|
||||
}
|
||||
idle := now.Sub(rep.LastUsedAt)
|
||||
if idle < r.cfg.EvictionGrace {
|
||||
if idle < r.evictionGrace() {
|
||||
continue
|
||||
}
|
||||
// A replica that has only just started is left alone however idle it
|
||||
@@ -128,7 +128,7 @@ func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
|
||||
// otherwise take turns evicting each other, paying a model load each
|
||||
// time — and a load is expensive enough that waiting is strictly
|
||||
// better than winning.
|
||||
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.cfg.MinLifetime {
|
||||
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.minLifetime() {
|
||||
continue
|
||||
}
|
||||
nodes := map[string]struct{}{}
|
||||
@@ -156,3 +156,34 @@ func (r *Reconciler) EvictionReason(model string) string {
|
||||
}
|
||||
return "nothing idle enough to stop"
|
||||
}
|
||||
|
||||
// evictionGrace is how long an on-demand replica must go unasked-for before
|
||||
// another model may have its card: the cluster's setting, else the
|
||||
// configuration's.
|
||||
func (r *Reconciler) evictionGrace() time.Duration {
|
||||
if d := time.Duration(r.evictGrace.Load()); d > 0 {
|
||||
return d
|
||||
}
|
||||
return r.cfg.EvictionGrace
|
||||
}
|
||||
|
||||
// minLifetime is how long a replica is safe from eviction once it is up.
|
||||
func (r *Reconciler) minLifetime() time.Duration {
|
||||
if d := time.Duration(r.minLife.Load()); d > 0 {
|
||||
return d
|
||||
}
|
||||
return r.cfg.MinLifetime
|
||||
}
|
||||
|
||||
// SetEvictionTimes puts the cluster's on_demand settings in force: how long
|
||||
// an idle on-demand replica keeps its card, and how long a new one is safe.
|
||||
// Zero keeps the configuration's.
|
||||
//
|
||||
// On one card shared by several models these are the wait between them: a
|
||||
// 3D job after a picture waited out five minutes of a picture model it no
|
||||
// longer needed.
|
||||
func (r *Reconciler) SetEvictionTimes(afterIdle, minLifetime time.Duration) {
|
||||
r.evictGrace.Store(int64(afterIdle))
|
||||
r.minLife.Store(int64(minLifetime))
|
||||
r.Notify()
|
||||
}
|
||||
@@ -205,9 +205,9 @@ func (r *Reconciler) WaitFor(model string) (heldBy string, eta time.Duration, ok
|
||||
// Evicted sooner, when it is no higher a class than the model
|
||||
// waiting; one put first is waited for to its idle timeout.
|
||||
if catalog.ClassRank(other.EffectiveClass()) <= catalog.ClassRank(m.EffectiveClass()) {
|
||||
evict := rep.LastUsedAt.Add(r.cfg.EvictionGrace)
|
||||
if !rep.ReadyAt.IsZero() && rep.ReadyAt.Add(r.cfg.MinLifetime).After(evict) {
|
||||
evict = rep.ReadyAt.Add(r.cfg.MinLifetime)
|
||||
evict := rep.LastUsedAt.Add(r.evictionGrace())
|
||||
if !rep.ReadyAt.IsZero() && rep.ReadyAt.Add(r.minLifetime()).After(evict) {
|
||||
evict = rep.ReadyAt.Add(r.minLifetime())
|
||||
}
|
||||
if evict.Before(h.release) {
|
||||
h.release = evict
|
||||
|
||||
@@ -249,7 +249,7 @@ func (r *Reconciler) victims(m catalog.Model) []victim {
|
||||
aboveMin := i < above
|
||||
tier := -1
|
||||
switch {
|
||||
case rank < myRank && other.Scaling.OnDemand() && idleFor(rep, now) >= r.cfg.EvictionGrace:
|
||||
case rank < myRank && other.Scaling.OnDemand() && idleFor(rep, now) >= r.evictionGrace():
|
||||
tier = 0
|
||||
case rank < myRank && aboveMin:
|
||||
tier = 2
|
||||
@@ -275,7 +275,7 @@ func (r *Reconciler) victims(m catalog.Model) []victim {
|
||||
}
|
||||
// Just started: stopping it throws away a model load that has
|
||||
// only now been paid for.
|
||||
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.cfg.MinLifetime {
|
||||
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.minLifetime() {
|
||||
continue
|
||||
}
|
||||
if len(nodesOf(rep)) > 1 {
|
||||
|
||||
@@ -264,6 +264,9 @@ type Reconciler struct {
|
||||
// because it is state about this moment and not about what the model is.
|
||||
// A model nobody has wanted has no entry and is simply not running.
|
||||
wanted map[string]time.Time
|
||||
// evictGrace and minLife are the cluster's on_demand settings, as
|
||||
// durations; zero keeps cfg's.
|
||||
evictGrace, minLife atomic.Int64
|
||||
// coldStart is how long each model's last replica took from placed to
|
||||
// ready, for telling a request waiting on it about how long it waits.
|
||||
coldStart map[string]time.Duration
|
||||
|
||||
@@ -1763,6 +1763,31 @@ func mustResolve(t *testing.T, h *harness, name string) catalog.Model {
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTheClustersOnDemandSettingsShortenTheWaitForACard(t *testing.T) {
|
||||
// One card, a picture model and a 3D model: a mesh after a picture
|
||||
// waited out five minutes of the picture model's protection.
|
||||
h := newHarness(t, testModel(1), gpuNode("1", 4))
|
||||
h.r.cat.Store(twoOnDemandModels(t))
|
||||
h.r.Want("llama")
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
reps := h.r.Replicas()
|
||||
h.r.RecordUsage(map[string]time.Time{reps[0].ID: h.clock.now()})
|
||||
h.advance(90 * time.Second)
|
||||
if got := h.r.evictable("second"); len(got) != 0 {
|
||||
t.Fatalf("offered at 90 s with the defaults: %+v", got)
|
||||
}
|
||||
h.r.SetEvictionTimes(30*time.Second, time.Minute)
|
||||
if got := h.r.evictable("second"); len(got) != 1 {
|
||||
t.Errorf("not offered at 90 s with 30 s idle and 1 min lifetime: %+v", got)
|
||||
}
|
||||
// Zero goes back to the configuration's.
|
||||
h.r.SetEvictionTimes(0, 0)
|
||||
if got := h.r.evictable("second"); len(got) != 0 {
|
||||
t.Errorf("offered after the settings were cleared: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFreshlyStartedReplicaIsNotEvicted(t *testing.T) {
|
||||
// What stops two models on a cluster with room for one from taking turns
|
||||
// evicting each other, paying a model load each time. A load is expensive
|
||||
|
||||
@@ -260,6 +260,10 @@ var Reference = []Block{
|
||||
{Key: "mirror", Type: "string", Doc: "Mirror is the mirror's file address, as nodes reach it:\nhttp://10.0.0.5:8081 — the port model files are served on, not the\nmanagement API. Set, the cluster is offline: nodes install engines\nfrom it and never from their publishers, releases come from it unless\nreleases names another place, and no registry is asked whether a\nmodel has newer weights. Python engines (vLLM, SGLang) come from PyPI,\nwhich no bundle carries; on an offline node they are installed by hand.", Nested: ""},
|
||||
{Key: "checksums", Type: "string", Doc: "Checksums is the file of engine checksums this control plane trusts,\nkept by llmgw bundle import and llmgw bundle trust; every engine file\na node fetches from the mirror is checked against it. Read again\nwhenever it changes. Empty is /etc/llmgw/offline.sha256.", Nested: ""},
|
||||
}},
|
||||
{Package: "config", Name: "OnDemandSettings", Title: "OnDemandSettings", Doc: "", Fields: []Field{
|
||||
{Key: "evict_after_idle", Type: "time.Duration", Doc: "EvictAfterIdle is how long an on-demand replica must go unasked-for\nbefore a model that wants its card may have it. Zero keeps 2m.", Nested: ""},
|
||||
{Key: "min_lifetime", Type: "time.Duration", Doc: "MinLifetime is how long a replica that has just come up is safe from\nthat, so two models do not take turns paying for loads. Zero keeps\n5m. On one card shared by a picture model and a 3D model, 1m each\nbrings the wait between a picture and its mesh to about a minute.\nBoth are in force on the leader within seconds.", Nested: ""},
|
||||
}},
|
||||
{Package: "discovery", Name: "Options", Title: "Options", Doc: "", Fields: []Field{
|
||||
{Key: "nvidia_smi", Type: "string", Doc: "NvidiaSMI is the path to nvidia-smi; empty means look it up on PATH.\nWorth configuring on nodes where the driver lives outside the default\npaths, which containerized driver installs often do.", Nested: ""},
|
||||
{Key: "ib_root", Type: "string", Doc: "IBRoot is the sysfs InfiniBand directory.", Nested: ""},
|
||||
@@ -313,6 +317,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: ""},
|
||||
@@ -438,6 +445,8 @@ 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: "on_demand", Type: "OnDemandSettings", Doc: "OnDemand is how models that load when asked for take turns on the\ncards.", Nested: "config.OnDemandSettings"},
|
||||
{Key: "agents", Type: "AgentSettings", Doc: "Agents are the nodes' agent settings: agent.yaml's keys but those an\nagent needs to start, for every node (default) and for one\n(nodes.<id>). Sent to the agents as they change; an agent whose\nsettings changed starts again on them, its engines serving\nthroughout.", Nested: "config.AgentSettings"},
|
||||
{Key: "firewall_networks", Type: "[]string", Doc: "FirewallNetworks are networks, besides those each machine reaches the\ncluster on, that llmgw's internal ports answer: a monitoring host on\nanother subnet, nodes behind a router. Written into every machine's\nfirewall by `server init`, `node add` and `server add`.", Nested: ""},
|
||||
{Key: "backup_recipients", Type: "[]string", Doc: "BackupRecipients encrypt every archive `llmgw server backup` writes, to\nwhoever holds one of their keys: age recipients (age1…) or SSH public\nkeys (ssh-ed25519 …, ssh-rsa …), as `age` takes them. An archive holds\nthe certificate authority's key and every secret in the store, and is\ncopied off the machine; encrypted, the copy is safe wherever it lands.\nEmpty writes archives unencrypted, readable by root only.", Nested: ""},
|
||||
|
||||
@@ -35,6 +35,8 @@ var Sources = []string{
|
||||
"internal/config/oidc.go",
|
||||
"internal/config/firewall.go",
|
||||
"internal/config/serving.go",
|
||||
"internal/config/presets.go",
|
||||
"internal/config/ondemand.go",
|
||||
"internal/config/agents.go",
|
||||
"internal/mail/mail.go",
|
||||
"internal/notify/notify.go",
|
||||
|
||||
@@ -41,6 +41,10 @@ type Header struct {
|
||||
// them; otherwise a head is EmbeddingLength / HeadCount.
|
||||
KeyLength uint64
|
||||
ValueLength uint64
|
||||
// Embedding is whether the model pools its tokens into one vector
|
||||
// (<arch>.pooling_type set and not none): an embedding model, which
|
||||
// llama.cpp serves with --embedding and does not chat.
|
||||
Embedding bool
|
||||
}
|
||||
|
||||
// KVBytesPerToken is what one token of context costs in llama.cpp's default
|
||||
@@ -265,6 +269,8 @@ func Read(r io.Reader) (Header, error) {
|
||||
h.KeyLength = num
|
||||
case "attention.value_length":
|
||||
h.ValueLength = num
|
||||
case "pooling_type":
|
||||
h.Embedding = num > 0
|
||||
}
|
||||
}
|
||||
// The tokenizer's arrays — megabytes of vocabulary — come after the
|
||||
|
||||
@@ -163,3 +163,28 @@ func TestArraysNestedDeepAreRefused(t *testing.T) {
|
||||
t.Errorf("ten thousand nested arrays: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmbeddingModelIsKnownByItsPooling(t *testing.T) {
|
||||
build := func(pooling uint32) []byte {
|
||||
var b bytes.Buffer
|
||||
w := func(v any) { _ = binary.Write(&b, binary.LittleEndian, v) }
|
||||
str := func(s string) { w(uint64(len(s))); b.WriteString(s) }
|
||||
u32 := func(k string, v uint32) { str(k); w(uint32(tUint32)); w(v) }
|
||||
b.WriteString("GGUF")
|
||||
w(uint32(3))
|
||||
w(uint64(0))
|
||||
w(uint64(3))
|
||||
str("general.architecture")
|
||||
w(uint32(tString))
|
||||
str("bert")
|
||||
u32("bert.block_count", 24)
|
||||
u32("bert.pooling_type", pooling)
|
||||
return b.Bytes()
|
||||
}
|
||||
if h, err := Read(bytes.NewReader(build(2))); err != nil || !h.Embedding {
|
||||
t.Errorf("CLS pooling: %+v %v", h, err)
|
||||
}
|
||||
if h, err := Read(bytes.NewReader(build(0))); err != nil || h.Embedding {
|
||||
t.Errorf("no pooling: %+v %v", h, err)
|
||||
}
|
||||
}
|
||||
@@ -329,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 {
|
||||
@@ -355,7 +359,9 @@ func (r *Router) Handler() http.Handler {
|
||||
}
|
||||
|
||||
if r.auth == nil {
|
||||
return r.refuseWhileStopped(r.refuseLoops(mux))
|
||||
h := r.refuseWhileStopped(r.refuseLoops(mux))
|
||||
r.selfHandler.Store(&h)
|
||||
return h
|
||||
}
|
||||
exempt := []string{"/health", "/ready", "/leader", "/v1/openapi.json", "/", "/about", "/_static/", "/_lang/", "/claim/",
|
||||
"/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/",
|
||||
@@ -369,7 +375,9 @@ func (r *Router) Handler() http.Handler {
|
||||
// Wrapping the whole mux rather than checking inside each handler: adding a
|
||||
// route must not be able to add an unauthenticated one by omission. Health
|
||||
// stays open because a load balancer has no key.
|
||||
return r.refuseWhileStopped(r.refuseLoops(auth.Middleware(r.auth, r.log, exempt...)(mux)))
|
||||
h := r.refuseWhileStopped(r.refuseLoops(auth.Middleware(r.auth, r.log, exempt...)(mux)))
|
||||
r.selfHandler.Store(&h)
|
||||
return h
|
||||
}
|
||||
|
||||
// refuseWhileStopped answers every request for a model with 503 while the
|
||||
|
||||
@@ -360,3 +360,32 @@ func TestAStandbyLeavesJobsToTheLeader(t *testing.T) {
|
||||
t.Errorf("the leader did not serve it: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAJobWaitingForTheCardDoesNotHoldUpAnotherModelsJob(t *testing.T) {
|
||||
// One at a time, an sf3d job waiting for the card held up a job for a
|
||||
// model that could have run.
|
||||
c := newJobComfy(t)
|
||||
rt, secret := jobRouter(t, c)
|
||||
rt.cfg.ActivationWait = 10 * time.Millisecond
|
||||
rt.SetActivator(&waitingActivator{fakeActivator: fakeActivator{onDemand: map[string]bool{"sleepy": true}},
|
||||
heldBy: "flux", eta: time.Hour})
|
||||
now := time.Now()
|
||||
put := func(id, model, body string, at time.Time) {
|
||||
if err := rt.store.PutJob(t.Context(), store.Job{ID: id, Owner: "project:" + mustProjectOf(t, rt, "key-1"),
|
||||
KeyID: "key-1", Endpoint: imagesPath, Model: model, Input: []byte(body), Status: store.JobQueued,
|
||||
CreatedAt: at, ExpiresAt: at.Add(jobWindow)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
put("job_wait", "sleepy", `{"model":"sleepy","prompt":"a cube"}`, now.Add(-time.Minute))
|
||||
put("job_go", "flux", `{"model":"flux","prompt":"a cube"}`, now)
|
||||
rt.wakeJobs()
|
||||
|
||||
got := waitJob(t, rt.Handler(), secret, "job_go", jobDone)
|
||||
if got["status"] != "completed" {
|
||||
t.Errorf("the other model's job: %v", got)
|
||||
}
|
||||
if w, _ := rt.store.JobByID(t.Context(), "job_wait"); w.Status != store.JobRunning {
|
||||
t.Errorf("the waiting job is %s", w.Status)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
@@ -27,6 +28,10 @@ const (
|
||||
jobCancelCheck = 2 * time.Second
|
||||
// jobMaxResult bounds what one job may answer: a mesh, a short video.
|
||||
jobMaxResult = 256 << 20
|
||||
// jobsPerModel is how many of one model's jobs run at once: its engine
|
||||
// works through them in turn anyway. jobsAtOnce bounds them all.
|
||||
jobsPerModel = 1
|
||||
jobsAtOnce = 8
|
||||
)
|
||||
|
||||
// jobUnplacedGrace is how long a job keeps asking for a model said to fit
|
||||
@@ -52,10 +57,28 @@ func (r *Router) runJobs(ctx context.Context) {
|
||||
owner := fmt.Sprintf("%s:%d:%s", host, os.Getpid(), newID(""))
|
||||
t := time.NewTicker(jobPoll)
|
||||
defer t.Stop()
|
||||
// Jobs run side by side, one per model at a time and jobsAtOnce in all:
|
||||
// one at a time, a job waiting for the card held up another model's job
|
||||
// that could have run.
|
||||
var mu sync.Mutex
|
||||
running := map[string]int{}
|
||||
total := 0
|
||||
for {
|
||||
for r.leading() {
|
||||
mu.Lock()
|
||||
if total >= jobsAtOnce {
|
||||
mu.Unlock()
|
||||
break
|
||||
}
|
||||
var skip []string
|
||||
for m, n := range running {
|
||||
if n >= jobsPerModel {
|
||||
skip = append(skip, m)
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
now := time.Now()
|
||||
j, ok, err := r.store.ClaimJob(ctx, owner, now, now.Add(jobLease))
|
||||
j, ok, err := r.store.ClaimJob(ctx, owner, now, now.Add(jobLease), skip...)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
r.log.Warn("could not look for jobs to serve", "err", err)
|
||||
@@ -65,7 +88,20 @@ func (r *Router) runJobs(ctx context.Context) {
|
||||
if !ok {
|
||||
break
|
||||
}
|
||||
r.workJob(ctx, owner, j)
|
||||
mu.Lock()
|
||||
running[j.Model]++
|
||||
total++
|
||||
mu.Unlock()
|
||||
go func(j store.Job) {
|
||||
r.workJob(ctx, owner, j)
|
||||
mu.Lock()
|
||||
if running[j.Model]--; running[j.Model] <= 0 {
|
||||
delete(running, j.Model)
|
||||
}
|
||||
total--
|
||||
mu.Unlock()
|
||||
r.wakeJobs() // room for the next
|
||||
}(j)
|
||||
}
|
||||
// A job's record outlives its file by nothing: both go at the
|
||||
// retention.
|
||||
|
||||
@@ -0,0 +1,937 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/jpeg" // a picture sent to look at may be a JPEG
|
||||
"image/png"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// --- MCP: pictures, 3D models, looking at pictures, workflows ---
|
||||
//
|
||||
// The gateway's own models offered as tools, so an agent — Claude Code
|
||||
// driving Blender, say — makes a concept, a mesh from it and has a render
|
||||
// looked at without a tool server of its own. Each tool makes its request
|
||||
// of the gateway as the caller would, under the caller's key: the same
|
||||
// allowlist, limits, licences left out, guardrails and accounting. What it
|
||||
// makes is kept as a file of the caller's, said by id and by the address
|
||||
// it is fetched from with the same key, since a tool server over HTTP
|
||||
// cannot write to the caller's disk.
|
||||
|
||||
const (
|
||||
// mcpBody bounds a tool call: up to four pictures to look at, as base64.
|
||||
mcpBody = 64 << 20
|
||||
// previewEdge is the long side of the picture shown to the agent beside
|
||||
// the file: enough to see it, not a megapixel of tokens.
|
||||
previewEdge = 512
|
||||
// lookEdge is the long side of a picture sent to a vision model:
|
||||
// 1280x720 is about 940 tokens.
|
||||
lookEdge = 1280
|
||||
// mcpWaitStreamed is how long a call answered as a stream waits for its
|
||||
// job, saying how far along it is meanwhile; mcpWaitPlain how long one
|
||||
// answered in one piece does, under the minute a proxy allows.
|
||||
mcpWaitStreamed = 10 * time.Minute
|
||||
mcpWaitPlain = 50 * time.Second
|
||||
mcpWaitMax = 15 * time.Minute
|
||||
mcpPoll = 2 * time.Second
|
||||
)
|
||||
|
||||
// The prompt endings of generate_image's styles: what a picture a mesh is
|
||||
// made from wants, and a surface that tiles.
|
||||
var imageStyles = map[string]string{
|
||||
"none": "",
|
||||
"concept": ", single object centered, orthographic three-quarter view, flat even studio lighting, no shadows, " +
|
||||
"no background elements, plain flat grey background, clean silhouette, game asset concept, high detail, no text, no watermark",
|
||||
"side": ", single object, exact side view (profile), orthographic, flat even studio lighting, no shadows, " +
|
||||
"plain flat grey background, clean silhouette, game asset concept, high detail, no text, no watermark",
|
||||
"texture": ", seamless tileable texture, top-down orthographic, flat even lighting, no shadows, no perspective, no text",
|
||||
}
|
||||
|
||||
// mcpMediaInstructions is said to an agent when it connects and the gateway
|
||||
// has something to make pictures or 3D models with.
|
||||
const mcpMediaInstructions = "Pictures, 3D models and a model that looks at pictures, on this gateway's own GPUs. " +
|
||||
"A typical loop: generate_image (style concept) -> generate_mesh from its file_id -> import the GLB -> render -> " +
|
||||
"review_images against the concept -> fix. Results are files: fetch them from their url with your API key. " +
|
||||
"AI meshes suit rocks, debris and background props; hard-surface hero assets are usually better built by hand " +
|
||||
"from the concept. A GPU holds one of these models at a time and switching costs a cold start, so batch calls " +
|
||||
"of the same kind; gateway_status says which is loaded."
|
||||
|
||||
// mediaModels are the gateway's models by what they do, by name, deduplicated
|
||||
// from the aliases that also resolve to them.
|
||||
type mediaModels struct{ images, meshes, vision, workflows []string }
|
||||
|
||||
func (r *Router) mediaModels() mediaModels {
|
||||
var out mediaModels
|
||||
seen := map[string]bool{}
|
||||
for _, m := range r.modelMap() {
|
||||
if seen[m.Name] || m.IsGuard() {
|
||||
continue
|
||||
}
|
||||
seen[m.Name] = true
|
||||
if m.MakesImages() {
|
||||
out.images = append(out.images, m.Name)
|
||||
}
|
||||
if m.MakesMeshes() {
|
||||
out.meshes = append(out.meshes, m.Name)
|
||||
}
|
||||
if m.Capabilities.Vision {
|
||||
out.vision = append(out.vision, m.Name)
|
||||
}
|
||||
if len(m.ComfyInputs()) > 0 {
|
||||
out.workflows = append(out.workflows, m.Name)
|
||||
}
|
||||
}
|
||||
for _, l := range [][]string{out.images, out.meshes, out.vision, out.workflows} {
|
||||
slices.Sort(l)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (mm mediaModels) any() bool {
|
||||
return len(mm.images)+len(mm.meshes)+len(mm.vision)+len(mm.workflows) > 0
|
||||
}
|
||||
|
||||
// defaultOf is the model a tool uses when the call names none: the one the
|
||||
// tool is best known with when the gateway has it, else the first.
|
||||
func defaultOf(models []string, preferred ...string) string {
|
||||
for _, p := range preferred {
|
||||
if slices.Contains(models, p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return ""
|
||||
}
|
||||
return models[0]
|
||||
}
|
||||
|
||||
func enumOrNone(models []string) map[string]any {
|
||||
s := map[string]any{"type": "string"}
|
||||
if len(models) > 0 {
|
||||
s["enum"] = models
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// mediaTools are the tools for what the gateway's models can do: none for
|
||||
// what it has no model for.
|
||||
func (r *Router) mediaTools() []map[string]any {
|
||||
mm := r.mediaModels()
|
||||
var tools []map[string]any
|
||||
fileArg := map[string]any{"type": "string",
|
||||
"description": "A picture: a /v1/files id (file-…), a data URL, or base64"}
|
||||
wait := map[string]any{"type": "integer", "minimum": 0, "maximum": int(mcpWaitMax.Seconds()),
|
||||
"description": "Most seconds to wait for it before answering with the job to ask after (job_status)"}
|
||||
if len(mm.images) > 0 {
|
||||
tools = append(tools, map[string]any{
|
||||
"name": "generate_image",
|
||||
"description": "Make a picture from a prompt (~15 s warm, longer when the GPU must switch models). " +
|
||||
"Keeps the PNG as a file and answers its file_id, url and seed, with a small preview. " +
|
||||
"style: 'concept' (one object, 3/4 view, grey background: good for generate_mesh), 'side' " +
|
||||
"(exact side view: best meshes for vehicles), 'texture' (seamless tileable surface), 'none' (the prompt as is).",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"prompt"},
|
||||
"properties": map[string]any{
|
||||
"prompt": map[string]any{"type": "string"},
|
||||
"style": map[string]any{"type": "string", "enum": []string{"none", "concept", "side", "texture"}, "default": "none"},
|
||||
"size": map[string]any{"type": "integer", "description": "Square side in px, a multiple of 16, 256-2048", "default": 1024},
|
||||
"seed": map[string]any{"type": "integer"},
|
||||
"negative_prompt": map[string]any{"type": "string"},
|
||||
"model": enumOrNone(mm.images),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(mm.meshes) > 0 {
|
||||
tools = append(tools, map[string]any{
|
||||
"name": "generate_mesh",
|
||||
"description": "Make a 3D model (GLB) from a picture of one object on a plain background — the gateway " +
|
||||
"cuts the background out itself — or from a prompt, for which a concept picture is made first. " +
|
||||
"Takes 30 s to a few minutes; waits for it, saying how far along it is when the client takes a stream. " +
|
||||
"Answers the GLB's file_id and url, and its licence. The mesh is about 1 m across, its orientation arbitrary.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"image": fileArg,
|
||||
"prompt": map[string]any{"type": "string", "description": "Used when no image is given"},
|
||||
"model": enumOrNone(mm.meshes),
|
||||
"seed": map[string]any{"type": "integer"},
|
||||
"resolution": map[string]any{"type": "integer", "description": "The model's own grid, a multiple of 32 (TripoSR: 256 is enough)"},
|
||||
"texture_resolution": map[string]any{"type": "integer", "minimum": 512, "maximum": 2048,
|
||||
"description": "For a model that textures its mesh (sf3d): the texture's side in px"},
|
||||
"inputs": map[string]any{"type": "object",
|
||||
"description": "Any other input the model's workflow declares (gateway_status lists them): remesh, vertex_count, foreground_ratio, albedo_match…"},
|
||||
"wait_seconds": wait,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(mm.vision) > 0 {
|
||||
tools = append(tools, map[string]any{
|
||||
"name": "review_images",
|
||||
"description": "Ask a vision model about 1-4 pictures — a render beside its concept, say. A cheap critic " +
|
||||
"for render-and-fix loops. json_schema makes it answer strict JSON. A cold start can take minutes " +
|
||||
"when the GPU was making pictures.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"images", "question"},
|
||||
"properties": map[string]any{
|
||||
"images": map[string]any{"type": "array", "items": fileArg, "minItems": 1, "maxItems": 4},
|
||||
"question": map[string]any{"type": "string"},
|
||||
"json_schema": map[string]any{"type": "object", "description": "JSON Schema the answer must follow"},
|
||||
"model": enumOrNone(mm.vision),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(mm.workflows) > 0 {
|
||||
tools = append(tools, map[string]any{
|
||||
"name": "run_workflow",
|
||||
"description": "Run a ComfyUI workflow the gateway serves, with the inputs its catalog entry declares " +
|
||||
"(gateway_status lists them). A file input is a /v1/files id, a data URL or base64. Answers its result file.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"model", "inputs"},
|
||||
"properties": map[string]any{
|
||||
"model": enumOrNone(mm.workflows),
|
||||
"inputs": map[string]any{"type": "object"},
|
||||
"wait_seconds": wait,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if mm.any() {
|
||||
tools = append(tools,
|
||||
map[string]any{
|
||||
"name": "job_status",
|
||||
"description": "Where a 3D model or workflow run stands, and its result file when done. wait_seconds waits for it to finish.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"job_id"},
|
||||
"properties": map[string]any{"job_id": map[string]any{"type": "string"}, "wait_seconds": wait},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"name": "gateway_status",
|
||||
"description": "The gateway's picture, 3D, vision and workflow models: whether each is loaded on a GPU now, " +
|
||||
"and a workflow's inputs; and your last few jobs. Use it to batch calls and avoid cold starts.",
|
||||
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// mediaCall is one tool call: who made it, and how to tell them how far it
|
||||
// has got.
|
||||
type mediaCall struct {
|
||||
req *http.Request
|
||||
progress func(percent float64, message string)
|
||||
// streamed is a call whose answer is a stream, which may wait longer.
|
||||
streamed bool
|
||||
}
|
||||
|
||||
func (c mediaCall) wait(asked *int) time.Duration {
|
||||
d := mcpWaitPlain
|
||||
if c.streamed {
|
||||
d = mcpWaitStreamed
|
||||
}
|
||||
if asked != nil {
|
||||
d = min(time.Duration(*asked)*time.Second, mcpWaitMax)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// isMediaTool is whether a tool is one of these.
|
||||
func isMediaTool(name string) bool {
|
||||
switch name {
|
||||
case "generate_image", "generate_mesh", "review_images", "run_workflow", "job_status", "gateway_status":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// callMediaTool runs one of the tools. As with the notes, a failure is a
|
||||
// tool result with isError set, which the model gets to read.
|
||||
func (r *Router) callMediaTool(c mediaCall, name string, args json.RawMessage) map[string]any {
|
||||
var res map[string]any
|
||||
var err error
|
||||
switch name {
|
||||
case "generate_image":
|
||||
res, err = r.toolGenerateImage(c, args)
|
||||
case "generate_mesh":
|
||||
res, err = r.toolGenerateMesh(c, args)
|
||||
case "review_images":
|
||||
res, err = r.toolReviewImages(c, args)
|
||||
case "run_workflow":
|
||||
res, err = r.toolRunWorkflow(c, args)
|
||||
case "job_status":
|
||||
res, err = r.toolJobStatus(c, args)
|
||||
case "gateway_status":
|
||||
res, err = r.toolGatewayStatus(c)
|
||||
default:
|
||||
err = errors.New("unknown tool " + name)
|
||||
}
|
||||
if err != nil {
|
||||
return toolText(err.Error(), true)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// self makes a request of the gateway's client API as the caller: their key,
|
||||
// their agent name, and the context the call was made in.
|
||||
func (r *Router) self(c mediaCall, method, path string, body any) (int, http.Header, []byte, error) {
|
||||
h := r.selfHandler.Load()
|
||||
if h == nil {
|
||||
return 0, nil, nil, errors.New("the gateway's API is not mounted here")
|
||||
}
|
||||
var rd *bytes.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
} else {
|
||||
rd = bytes.NewReader(nil)
|
||||
}
|
||||
out, err := http.NewRequestWithContext(c.req.Context(), method, path, rd)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
for _, k := range []string{"Authorization", "X-Api-Key", agentHeader, "X-Forwarded-For", "X-Real-Ip"} {
|
||||
if v := c.req.Header.Get(k); v != "" {
|
||||
out.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
out.RemoteAddr = c.req.RemoteAddr
|
||||
if body != nil {
|
||||
out.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// A picture or a mesh comes back whole: past the 1 MiB a judge's
|
||||
// answer is held to.
|
||||
w := &bufferWriter{header: http.Header{}, max: mcpBody * 2}
|
||||
(*h).ServeHTTP(w, out)
|
||||
status := w.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
return status, w.header, w.buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// selfJSON is self for an answer in JSON, a failure said as the API said it.
|
||||
func (r *Router) selfJSON(c mediaCall, method, path string, body, into any) (http.Header, error) {
|
||||
status, h, b, err := r.self(c, method, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status >= 300 {
|
||||
return h, fmt.Errorf("%s %s: %d %s", method, path, status, errorMessage(b))
|
||||
}
|
||||
if into == nil {
|
||||
return h, nil
|
||||
}
|
||||
return h, json.Unmarshal(b, into)
|
||||
}
|
||||
|
||||
// fileURL is where a file is fetched from, with the caller's key, as the
|
||||
// caller reached the gateway.
|
||||
func fileURL(req *http.Request, id string) string {
|
||||
scheme := "http"
|
||||
if req.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if p := req.Header.Get("X-Forwarded-Proto"); p != "" {
|
||||
scheme = strings.TrimSpace(strings.Split(p, ",")[0])
|
||||
}
|
||||
host := req.Host
|
||||
if h := req.Header.Get("X-Forwarded-Host"); h != "" {
|
||||
host = strings.TrimSpace(strings.Split(h, ",")[0])
|
||||
}
|
||||
return scheme + "://" + host + "/v1/files/" + id + "/content"
|
||||
}
|
||||
|
||||
// keepFile keeps what a tool made as the caller's file, for as long as a
|
||||
// job's result is kept.
|
||||
func (r *Router) keepFile(c mediaCall, name string, content []byte) (store.File, error) {
|
||||
p, ok := auth.FromContext(c.req.Context())
|
||||
if !ok || r.store == nil {
|
||||
return store.File{}, errors.New("this gateway keeps no files (it runs without authentication or a store)")
|
||||
}
|
||||
if r.noStore(callerSubject(c.req)) {
|
||||
return store.File{}, errors.New("the gateway's policy for this caller keeps nothing, so no file is kept")
|
||||
}
|
||||
_, _, _, days := r.batchCfg()
|
||||
now := time.Now()
|
||||
f := store.File{ID: newID("file-"), Owner: ownerOf(p), Purpose: purposeJobOutput, Filename: name,
|
||||
Bytes: int64(len(content)), CreatedAt: now, ExpiresAt: now.AddDate(0, 0, days)}
|
||||
return f, r.store.PutFile(c.req.Context(), f, content)
|
||||
}
|
||||
|
||||
// picture reads a picture argument: a file of the caller's by id, a data URL,
|
||||
// or base64.
|
||||
func (r *Router) picture(c mediaCall, s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
switch {
|
||||
case s == "":
|
||||
return nil, errors.New("no picture given")
|
||||
case strings.HasPrefix(s, "file-"):
|
||||
p, ok := auth.FromContext(c.req.Context())
|
||||
if !ok || r.store == nil {
|
||||
return nil, errors.New("this gateway keeps no files: send the picture as base64 or a data URL")
|
||||
}
|
||||
f, err := r.store.FileByID(c.req.Context(), s)
|
||||
if errors.Is(err, store.ErrNotFound) || err == nil && f.Owner != ownerOf(p) {
|
||||
return nil, fmt.Errorf("no file %s of yours", s)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.store.FileContent(c.req.Context(), s)
|
||||
case strings.HasPrefix(s, "data:"):
|
||||
_, after, ok := strings.Cut(s, ";base64,")
|
||||
if !ok {
|
||||
return nil, errors.New("a data URL without base64 in it")
|
||||
}
|
||||
s = after
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, errors.New("the picture is neither a file id, a data URL nor base64")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func dataURL(png []byte) string {
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
|
||||
}
|
||||
|
||||
// shrink returns a picture as a PNG whose long side is at most edge.
|
||||
func shrink(pic []byte, edge int) ([]byte, error) {
|
||||
src, format, err := image.Decode(bytes.NewReader(pic))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not a picture this gateway reads (PNG or JPEG): %w", err)
|
||||
}
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if max(w, h) <= edge {
|
||||
if format == "png" {
|
||||
return pic, nil
|
||||
}
|
||||
edge = max(w, h) // only re-encoded
|
||||
}
|
||||
scale := float64(edge) / float64(max(w, h))
|
||||
dw, dh := max(1, int(float64(w)*scale+0.5)), max(1, int(float64(h)*scale+0.5))
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, dw, dh))
|
||||
// Each pixel the average of the box of pixels it stands for.
|
||||
for y := range dh {
|
||||
y0, y1 := b.Min.Y+y*h/dh, b.Min.Y+max((y+1)*h/dh, y*h/dh+1)
|
||||
for x := range dw {
|
||||
x0, x1 := b.Min.X+x*w/dw, b.Min.X+max((x+1)*w/dw, x*w/dw+1)
|
||||
var sr, sg, sb, sa, n uint64
|
||||
for yy := y0; yy < y1; yy++ {
|
||||
for xx := x0; xx < x1; xx++ {
|
||||
cr, cg, cb, ca := src.At(xx, yy).RGBA()
|
||||
sr, sg, sb, sa, n = sr+uint64(cr), sg+uint64(cg), sb+uint64(cb), sa+uint64(ca), n+1
|
||||
}
|
||||
}
|
||||
// Premultiplied sums, back to straight colour.
|
||||
if sa == 0 {
|
||||
continue
|
||||
}
|
||||
dst.SetNRGBA(x, y, color.NRGBA{
|
||||
R: uint8(sr * 0xff / sa), G: uint8(sg * 0xff / sa), B: uint8(sb * 0xff / sa), A: uint8(sa / n >> 8),
|
||||
})
|
||||
}
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// --- the tools ---
|
||||
|
||||
func (r *Router) toolGenerateImage(c mediaCall, raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Style string `json:"style"`
|
||||
NegativePrompt string `json:"negative_prompt"`
|
||||
Model string `json:"model"`
|
||||
Size int `json:"size"`
|
||||
Seed *int64 `json:"seed"`
|
||||
}
|
||||
if err := decodeArgs(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, _, err := r.makeImage(c, a.Prompt, a.Style, a.NegativePrompt, a.Model, a.Size, a.Seed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// makeImage makes a picture and keeps it: the tool's answer, and the PNG.
|
||||
func (r *Router) makeImage(c mediaCall, prompt, style, negative, model string, size int, seed *int64) (map[string]any, []byte, error) {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return nil, nil, errors.New("prompt is required")
|
||||
}
|
||||
if style == "" {
|
||||
style = "none"
|
||||
}
|
||||
suffix, ok := imageStyles[style]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("style %q: concept, side, texture or none", style)
|
||||
}
|
||||
if model == "" {
|
||||
model = defaultOf(r.mediaModels().images, "flux-schnell")
|
||||
}
|
||||
if size == 0 {
|
||||
size = 1024
|
||||
}
|
||||
body := map[string]any{"model": model, "prompt": prompt + suffix, "n": 1,
|
||||
"size": fmt.Sprintf("%dx%d", size, size), "response_format": "b64_json"}
|
||||
if seed != nil {
|
||||
body["seed"] = *seed
|
||||
}
|
||||
if negative != "" {
|
||||
body["negative_prompt"] = negative
|
||||
}
|
||||
c.progress(5, "making the picture with "+model)
|
||||
var out struct {
|
||||
Data []struct {
|
||||
B64 string `json:"b64_json"`
|
||||
} `json:"data"`
|
||||
}
|
||||
h, err := r.selfJSON(c, http.MethodPost, imagesPath, body, &out)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(out.Data) == 0 {
|
||||
return nil, nil, errors.New(model + " made no picture")
|
||||
}
|
||||
pic, err := base64.StdEncoding.DecodeString(out.Data[0].B64)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info := map[string]any{"model": model, "width": size, "height": size}
|
||||
if s, err := strconv.ParseInt(h.Get("x-llmgw-seed"), 10, 64); err == nil {
|
||||
info["seed"] = s
|
||||
}
|
||||
if m, ok := r.modelMap()[model]; ok && m.License != nil {
|
||||
info["license"] = m.License
|
||||
}
|
||||
content := []map[string]any{}
|
||||
if f, err := r.keepFile(c, "image-"+strings.TrimPrefix(newID(""), "-")+".png", pic); err == nil {
|
||||
info["file_id"], info["url"], info["bytes"] = f.ID, fileURL(c.req, f.ID), f.Bytes
|
||||
} else {
|
||||
// Nowhere to keep it: the picture itself, whole.
|
||||
info["note"] = err.Error() + "; the picture is in this answer at full size"
|
||||
content = append(content, map[string]any{"type": "image", "mimeType": "image/png", "data": out.Data[0].B64})
|
||||
}
|
||||
text, _ := json.Marshal(info)
|
||||
content = append([]map[string]any{{"type": "text", "text": string(text)}}, content...)
|
||||
if _, kept := info["file_id"]; kept {
|
||||
if small, err := shrink(pic, previewEdge); err == nil {
|
||||
content = append(content, map[string]any{"type": "image", "mimeType": "image/png",
|
||||
"data": base64.StdEncoding.EncodeToString(small)})
|
||||
}
|
||||
}
|
||||
return map[string]any{"content": content, "isError": false}, pic, nil
|
||||
}
|
||||
|
||||
func (r *Router) toolGenerateMesh(c mediaCall, raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Image, Prompt, Model string
|
||||
Seed *int64
|
||||
Resolution int
|
||||
TextureResolution int `json:"texture_resolution"`
|
||||
Inputs map[string]any
|
||||
WaitSeconds *int `json:"wait_seconds"`
|
||||
}
|
||||
if err := decodeArgs(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mm := r.mediaModels()
|
||||
if a.Model == "" {
|
||||
a.Model = defaultOf(mm.meshes, "sf3d", "triposr")
|
||||
}
|
||||
m, ok := r.modelMap()[a.Model]
|
||||
if !ok || !m.MakesMeshes() {
|
||||
return nil, fmt.Errorf("%q makes no 3D models here; these do: %s", a.Model, strings.Join(mm.meshes, ", "))
|
||||
}
|
||||
extra := map[string]any{}
|
||||
var pic []byte
|
||||
var err error
|
||||
switch {
|
||||
case a.Image != "":
|
||||
if pic, err = r.picture(c, a.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case a.Prompt != "":
|
||||
res, made, err := r.makeImage(c, a.Prompt, "concept", "", "", 0, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the concept picture: %w", err)
|
||||
}
|
||||
pic = made
|
||||
var info map[string]any
|
||||
if json.Unmarshal([]byte(res["content"].([]map[string]any)[0]["text"].(string)), &info) == nil {
|
||||
extra["concept_file_id"], extra["concept_seed"] = info["file_id"], info["seed"]
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("an image, or a prompt to make one from")
|
||||
}
|
||||
|
||||
// A model whose workflow declares its inputs takes them at the
|
||||
// workflows' path, every one it names; one that does not, the 3D path's
|
||||
// seed and grid.
|
||||
path, body := meshesPath, map[string]any{"model": a.Model, "image": dataURL(pic)}
|
||||
if declared := m.ComfyInputs(); len(declared) > 0 {
|
||||
names := map[string]string{}
|
||||
var image string
|
||||
for _, in := range declared {
|
||||
names[in.Name] = in.Type
|
||||
if image == "" && in.Type == "image" {
|
||||
image = in.Name
|
||||
}
|
||||
}
|
||||
if image == "" {
|
||||
return nil, fmt.Errorf("%s's workflow takes no picture", a.Model)
|
||||
}
|
||||
inputs := map[string]any{image: dataURL(pic)}
|
||||
for k, v := range a.Inputs {
|
||||
inputs[k] = v
|
||||
}
|
||||
|
||||
// A textured mesh's resolution is its texture's; a grid's, the grid's.
|
||||
if _, ok := names["resolution"]; ok {
|
||||
switch {
|
||||
case a.TextureResolution > 0:
|
||||
inputs["resolution"] = a.TextureResolution
|
||||
case a.Resolution > 0:
|
||||
inputs["resolution"] = a.Resolution
|
||||
}
|
||||
}
|
||||
path, body = workflowsPath, map[string]any{"model": a.Model, "inputs": inputs}
|
||||
if a.Seed != nil {
|
||||
body["seed"] = *a.Seed
|
||||
}
|
||||
} else {
|
||||
if a.Seed != nil {
|
||||
body["seed"] = *a.Seed
|
||||
}
|
||||
if a.Resolution > 0 {
|
||||
body["resolution"] = a.Resolution
|
||||
}
|
||||
}
|
||||
var job struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if _, err := r.selfJSON(c, http.MethodPost, path, body, &job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.progress(10, "queued as "+job.ID)
|
||||
return r.waitJob(c, job.ID, c.wait(a.WaitSeconds), extra)
|
||||
}
|
||||
|
||||
func (r *Router) toolRunWorkflow(c mediaCall, raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Model string
|
||||
Inputs map[string]any
|
||||
WaitSeconds *int `json:"wait_seconds"`
|
||||
}
|
||||
if err := decodeArgs(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := r.modelMap()[a.Model]
|
||||
if !ok || len(m.ComfyInputs()) == 0 {
|
||||
return nil, fmt.Errorf("%q runs no workflow here; these do: %s", a.Model, strings.Join(r.mediaModels().workflows, ", "))
|
||||
}
|
||||
// A file input given as a file id goes as it is: the API takes those.
|
||||
var job struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if _, err := r.selfJSON(c, http.MethodPost, workflowsPath, map[string]any{"model": a.Model, "inputs": a.Inputs}, &job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.progress(10, "queued as "+job.ID)
|
||||
return r.waitJob(c, job.ID, c.wait(a.WaitSeconds), nil)
|
||||
}
|
||||
|
||||
func (r *Router) toolJobStatus(c mediaCall, raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
JobID string `json:"job_id"`
|
||||
WaitSeconds *int `json:"wait_seconds"`
|
||||
}
|
||||
if err := decodeArgs(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.JobID == "" {
|
||||
return nil, errors.New("job_id is required")
|
||||
}
|
||||
d := time.Duration(0)
|
||||
if a.WaitSeconds != nil {
|
||||
d = c.wait(a.WaitSeconds)
|
||||
}
|
||||
return r.waitJob(c, a.JobID, d, nil)
|
||||
}
|
||||
|
||||
// jobView is the part of a job a tool says.
|
||||
type jobView struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Progress json.RawMessage `json:"progress"`
|
||||
OutputFileID *string `json:"output_file_id"`
|
||||
ContentType *string `json:"content_type"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
License json.RawMessage `json:"license"`
|
||||
}
|
||||
|
||||
// waitJob asks after a job until it is done or d has passed, saying how far
|
||||
// along it is, and answers where it stands: its file when done.
|
||||
func (r *Router) waitJob(c mediaCall, id string, d time.Duration, extra map[string]any) (map[string]any, error) {
|
||||
deadline := time.Now().Add(d)
|
||||
var j jobView
|
||||
for {
|
||||
if _, err := r.selfJSON(c, http.MethodGet, workflowsPath+"/"+id, nil, &j); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if store.JobDone(j.Status) || !time.Now().Before(deadline) {
|
||||
break
|
||||
}
|
||||
pct, msg := jobProgress(j)
|
||||
c.progress(pct, msg)
|
||||
select {
|
||||
case <-c.req.Context().Done():
|
||||
return nil, c.req.Context().Err()
|
||||
case <-time.After(mcpPoll):
|
||||
}
|
||||
}
|
||||
info := map[string]any{"job_id": j.ID, "model": j.Model, "status": j.Status}
|
||||
for k, v := range extra {
|
||||
if v != nil {
|
||||
info[k] = v
|
||||
}
|
||||
}
|
||||
if len(j.License) > 0 && string(j.License) != "null" {
|
||||
info["license"] = j.License
|
||||
}
|
||||
switch {
|
||||
case j.Status == store.JobCompleted && j.OutputFileID != nil:
|
||||
info["file_id"], info["url"] = *j.OutputFileID, fileURL(c.req, *j.OutputFileID)
|
||||
if j.ContentType != nil {
|
||||
info["content_type"] = *j.ContentType
|
||||
}
|
||||
if r.store != nil {
|
||||
if f, err := r.store.FileByID(c.req.Context(), *j.OutputFileID); err == nil {
|
||||
info["bytes"] = f.Bytes
|
||||
}
|
||||
}
|
||||
case store.JobDone(j.Status):
|
||||
if j.Error != nil {
|
||||
info["error"] = j.Error.Message
|
||||
}
|
||||
b, _ := json.Marshal(info)
|
||||
return toolText(string(b), true), nil
|
||||
default:
|
||||
info["progress"] = j.Progress
|
||||
info["next"] = "not done yet: call job_status with this job_id (and wait_seconds) to wait for it"
|
||||
}
|
||||
b, _ := json.Marshal(info)
|
||||
return toolText(string(b), false), nil
|
||||
}
|
||||
|
||||
// jobProgress reads a job's progress as a percent and a phrase.
|
||||
func jobProgress(j jobView) (float64, string) {
|
||||
var p struct {
|
||||
State string `json:"state"`
|
||||
Ahead int `json:"ahead"`
|
||||
Percent float64 `json:"percent"`
|
||||
Node string `json:"node_class"`
|
||||
HeldBy string `json:"held_by"`
|
||||
}
|
||||
_ = json.Unmarshal(j.Progress, &p)
|
||||
switch {
|
||||
case p.State == "queued":
|
||||
return 10, fmt.Sprintf("%s: queued, %d ahead", j.ID, p.Ahead)
|
||||
case p.State == "waiting_gpu":
|
||||
return 10, j.ID + ": waiting for the GPU, held by " + p.HeldBy
|
||||
case p.State == "running":
|
||||
return 10 + 0.9*p.Percent, fmt.Sprintf("%s: %s %.0f%%", j.ID, p.Node, p.Percent)
|
||||
}
|
||||
return 10, j.ID + ": " + j.Status
|
||||
}
|
||||
|
||||
func (r *Router) toolReviewImages(c mediaCall, raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Images []string
|
||||
Question string
|
||||
JSONSchema map[string]any `json:"json_schema"`
|
||||
Model string
|
||||
}
|
||||
if err := decodeArgs(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(a.Images) == 0 || len(a.Images) > 4 {
|
||||
return nil, errors.New("one to four images")
|
||||
}
|
||||
if strings.TrimSpace(a.Question) == "" {
|
||||
return nil, errors.New("question is required")
|
||||
}
|
||||
if a.Model == "" {
|
||||
a.Model = defaultOf(r.mediaModels().vision, "vlm")
|
||||
}
|
||||
if a.Model == "" {
|
||||
return nil, errors.New("no model here looks at pictures")
|
||||
}
|
||||
parts := []map[string]any{{"type": "text", "text": a.Question}}
|
||||
for i, s := range a.Images {
|
||||
pic, err := r.picture(c, s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("image %d: %w", i+1, err)
|
||||
}
|
||||
small, err := shrink(pic, lookEdge)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("image %d: %w", i+1, err)
|
||||
}
|
||||
parts = append(parts, map[string]any{"type": "image_url", "image_url": map[string]any{"url": dataURL(small)}})
|
||||
}
|
||||
body := map[string]any{"model": a.Model, "messages": []map[string]any{{"role": "user", "content": parts}}}
|
||||
if a.JSONSchema != nil {
|
||||
body["response_format"] = map[string]any{"type": "json_schema",
|
||||
"json_schema": map[string]any{"name": "answer", "schema": a.JSONSchema, "strict": true}}
|
||||
}
|
||||
c.progress(10, "asking "+a.Model)
|
||||
var out struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if _, err := r.selfJSON(c, http.MethodPost, "/v1/chat/completions", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return nil, errors.New(a.Model + " gave no answer")
|
||||
}
|
||||
return toolText(out.Choices[0].Message.Content, false), nil
|
||||
}
|
||||
|
||||
func (r *Router) toolGatewayStatus(c mediaCall) (map[string]any, error) {
|
||||
var models struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if _, err := r.selfJSON(c, http.MethodGet, "/v1/models", nil, &models); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var listed []map[string]any
|
||||
for _, m := range models.Data {
|
||||
caps, _ := m["capabilities"].(map[string]any)
|
||||
if caps["images"] != true && caps["meshes"] != true && caps["vision"] != true && m["workflow"] == nil {
|
||||
continue
|
||||
}
|
||||
e := map[string]any{"id": m["id"], "loaded": m["loaded"], "capabilities": caps}
|
||||
if w := m["workflow"]; w != nil {
|
||||
e["workflow"] = w
|
||||
}
|
||||
if l := m["license"]; l != nil {
|
||||
e["license"] = l
|
||||
}
|
||||
listed = append(listed, e)
|
||||
}
|
||||
out := map[string]any{"models": listed}
|
||||
var jobs struct {
|
||||
Data []jobView `json:"data"`
|
||||
}
|
||||
if _, err := r.selfJSON(c, http.MethodGet, workflowsPath+"?limit=5", nil, &jobs); err == nil {
|
||||
recent := make([]map[string]any, 0, len(jobs.Data))
|
||||
for _, j := range jobs.Data {
|
||||
recent = append(recent, map[string]any{"id": j.ID, "model": j.Model, "status": j.Status})
|
||||
}
|
||||
out["recent_jobs"] = recent
|
||||
}
|
||||
return toolJSON(out), nil
|
||||
}
|
||||
|
||||
// decodeArgs reads a tool's arguments, by their JSON names: a field's own
|
||||
// name in lower case unless its tag says.
|
||||
func decodeArgs(raw json.RawMessage, into any) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
raw = []byte("{}")
|
||||
}
|
||||
if err := json.Unmarshal(raw, into); err != nil {
|
||||
return fmt.Errorf("the arguments: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamWriter answers a tool call as server-sent events: progress
|
||||
// notifications while it runs, then the answer.
|
||||
type streamWriter struct {
|
||||
mu sync.Mutex
|
||||
w http.ResponseWriter
|
||||
token json.RawMessage
|
||||
}
|
||||
|
||||
func (s *streamWriter) write(text string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, _ = fmt.Fprint(s.w, text)
|
||||
if f, ok := s.w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *streamWriter) event(v any) {
|
||||
b, _ := json.Marshal(v)
|
||||
s.write("event: message\ndata: " + string(b) + "\n\n")
|
||||
}
|
||||
|
||||
// keepAlive writes a comment every while until stop is closed: a picture
|
||||
// whose model starts cold is minutes of nothing said, which a proxy reads as
|
||||
// a connection gone idle.
|
||||
func (s *streamWriter) keepAlive(stop <-chan struct{}, every time.Duration) {
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
s.write(": still working\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *streamWriter) progress(percent float64, message string) {
|
||||
if len(s.token) == 0 {
|
||||
return
|
||||
}
|
||||
s.event(map[string]any{"jsonrpc": "2.0", "method": "notifications/progress", "params": map[string]any{
|
||||
"progressToken": s.token, "progress": percent, "total": 100, "message": message}})
|
||||
}
|
||||
|
||||
// acceptsStream is a client that takes a POST's answer as a stream, as the
|
||||
// streamable HTTP transport lets it.
|
||||
func acceptsStream(req *http.Request) bool {
|
||||
return strings.Contains(req.Header.Get("Accept"), "text/event-stream")
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
)
|
||||
|
||||
// mcp makes one JSON-RPC call at /mcp and answers its result; streamed, as a
|
||||
// client that takes server-sent events, it answers the events' data too.
|
||||
func mcp(t *testing.T, h http.Handler, secret, method string, params any, streamed bool) (map[string]any, []string) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if secret != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
}
|
||||
if streamed {
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: %d %s", method, rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
var events []string
|
||||
if streamed {
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "text/event-stream" {
|
||||
t.Fatalf("a streamed call answered %s: %s", ct, body)
|
||||
}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if d, ok := strings.CutPrefix(line, "data: "); ok {
|
||||
events = append(events, d)
|
||||
}
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Fatalf("no events: %s", body)
|
||||
}
|
||||
body = events[len(events)-1]
|
||||
}
|
||||
var resp struct {
|
||||
Result map[string]any `json:"result"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &resp); err != nil || resp.Error != nil {
|
||||
t.Fatalf("%s: %v %s", method, err, body)
|
||||
}
|
||||
return resp.Result, events
|
||||
}
|
||||
|
||||
func toolNames(res map[string]any) []string {
|
||||
var names []string
|
||||
for _, tl := range res["tools"].([]any) {
|
||||
names = append(names, tl.(map[string]any)["name"].(string))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// resultInfo is a tool result's first text, as the JSON it holds.
|
||||
func resultInfo(t *testing.T, res map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
if res["isError"] == true {
|
||||
t.Fatalf("the tool failed: %v", res["content"])
|
||||
}
|
||||
var info map[string]any
|
||||
text := res["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
if err := json.Unmarshal([]byte(text), &info); err != nil {
|
||||
t.Fatalf("not JSON: %s", text)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func TestTheMCPToolsAreWhatTheGatewaysModelsCanDo(t *testing.T) {
|
||||
c := newMeshComfy(t)
|
||||
rt, secret := meshRouter(t, c)
|
||||
h := rt.Handler()
|
||||
init, _ := mcp(t, h, secret, "initialize", map[string]any{"protocolVersion": "2025-06-18"}, false)
|
||||
if init["protocolVersion"] != "2025-06-18" || !strings.Contains(init["instructions"].(string), "batch calls") {
|
||||
t.Errorf("initialize: %v", init)
|
||||
}
|
||||
list, _ := mcp(t, h, secret, "tools/list", map[string]any{}, false)
|
||||
names := strings.Join(toolNames(list), ",")
|
||||
for _, want := range []string{"remember", "generate_image", "generate_mesh", "job_status", "gateway_status"} {
|
||||
if !strings.Contains(names, want) {
|
||||
t.Errorf("no %s in %s", want, names)
|
||||
}
|
||||
}
|
||||
// Nothing here looks at pictures or declares a workflow's inputs.
|
||||
if strings.Contains(names, "review_images") || strings.Contains(names, "run_workflow") {
|
||||
t.Errorf("tools for models the gateway has not: %s", names)
|
||||
}
|
||||
|
||||
// A gateway with only text models offers the notes alone.
|
||||
plain := newTestRouter(t, baseConfig()).Handler()
|
||||
list, _ = mcp(t, plain, "", "tools/list", map[string]any{}, false)
|
||||
if got := strings.Join(toolNames(list), ","); got != "remember,recall,forget" {
|
||||
t.Errorf("a text-only gateway offers %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshIsMadeThroughMCPAndKeptAsTheCallersFile(t *testing.T) {
|
||||
c := newMeshComfy(t)
|
||||
rt, secret := meshRouter(t, c)
|
||||
h := rt.Handler()
|
||||
args := map[string]any{"model": "trellis2", "image": "data:image/png;base64," + testPicture(t), "seed": 3}
|
||||
|
||||
for _, streamed := range []bool{false, true} {
|
||||
res, events := mcp(t, h, secret, "tools/call", map[string]any{"name": "generate_mesh", "arguments": args,
|
||||
"_meta": map[string]any{"progressToken": "tok"}}, streamed)
|
||||
info := resultInfo(t, res)
|
||||
if info["status"] != "completed" || info["license"].(map[string]any)["name"] != "MIT" {
|
||||
t.Fatalf("streamed %v: %v", streamed, info)
|
||||
}
|
||||
id, _ := info["file_id"].(string)
|
||||
if !strings.HasSuffix(info["url"].(string), "/v1/files/"+id+"/content") {
|
||||
t.Errorf("url %v", info["url"])
|
||||
}
|
||||
// Fetched with the same key, it is the mesh.
|
||||
code, _, file := call(t, h, secret, http.MethodGet, "/v1/files/"+id+"/content", nil)
|
||||
if code != http.StatusOK || !strings.HasPrefix(file, "glTF") {
|
||||
t.Fatalf("the file is not the mesh: %d", code)
|
||||
}
|
||||
if streamed && !strings.Contains(strings.Join(events, "\n"), `"progressToken":"tok"`) {
|
||||
t.Errorf("no progress said while it ran: %v", events)
|
||||
}
|
||||
}
|
||||
// The job went through the client API as the caller: it is theirs.
|
||||
_, list, _ := call(t, h, secret, http.MethodGet, meshesPath, nil)
|
||||
if n := len(list["data"].([]any)); n != 2 {
|
||||
t.Errorf("%d jobs of the caller's, want the two made", n)
|
||||
}
|
||||
// Under no key, nothing is made.
|
||||
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": "generate_mesh", "arguments": args}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(b))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("a call with no key: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPictureMadeThroughMCPComesWholeWhereNoFileCanBeKept(t *testing.T) {
|
||||
rt, queued, mu := imageRouter(t)
|
||||
h := rt.Handler()
|
||||
res, _ := mcp(t, h, "", "tools/call", map[string]any{"name": "generate_image",
|
||||
"arguments": map[string]any{"prompt": "a rock", "style": "concept", "size": 512, "seed": 9}}, false)
|
||||
info := resultInfo(t, res)
|
||||
if info["seed"] != 9.0 || info["model"] != "flux" || !strings.Contains(info["note"].(string), "keeps no files") {
|
||||
t.Errorf("info %v", info)
|
||||
}
|
||||
content := res["content"].([]any)
|
||||
if len(content) != 2 || content[1].(map[string]any)["type"] != "image" {
|
||||
t.Fatalf("the picture is not in the answer: %v", content)
|
||||
}
|
||||
if b, _ := base64.StdEncoding.DecodeString(content[1].(map[string]any)["data"].(string)); string(b) != "png-bytes" {
|
||||
t.Errorf("picture %q", b)
|
||||
}
|
||||
mu.Lock()
|
||||
wf, _ := json.Marshal(*queued)
|
||||
mu.Unlock()
|
||||
if !strings.Contains(string(wf), "a rock, single object centered") || !strings.Contains(string(wf), `"width":512`) {
|
||||
t.Errorf("the style was not added to the prompt: %s", wf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPictureIsShrunkToItsLongSide(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
_ = png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 2000, 1000)))
|
||||
out, err := shrink(b.Bytes(), 512)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
im, err := png.Decode(bytes.NewReader(out))
|
||||
if err != nil || im.Bounds().Dx() != 512 || im.Bounds().Dy() != 256 {
|
||||
t.Fatalf("%v %v", im.Bounds(), err)
|
||||
}
|
||||
b.Reset()
|
||||
_ = png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 100, 50)))
|
||||
if out, _ := shrink(b.Bytes(), 512); !bytes.Equal(out, b.Bytes()) {
|
||||
t.Error("a small PNG was re-encoded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWorkflowRunsThroughMCPFromItsDeclaredInputs(t *testing.T) {
|
||||
wav := testWAV()
|
||||
c := newRunComfy(t, wav, "song_00001_.wav")
|
||||
rt, secret, _ := runRouter(t, c, songWorkflow, catalog.OutputAudio)
|
||||
h := rt.Handler()
|
||||
list, _ := mcp(t, h, secret, "tools/list", map[string]any{}, false)
|
||||
if !strings.Contains(strings.Join(toolNames(list), ","), "run_workflow") {
|
||||
t.Fatalf("no run_workflow for a model that declares its inputs: %v", toolNames(list))
|
||||
}
|
||||
res, _ := mcp(t, h, secret, "tools/call", map[string]any{"name": "run_workflow", "arguments": map[string]any{
|
||||
"model": "ace", "inputs": map[string]any{"tags": "synthwave", "cover": "data:image/png;base64," + testPicture(t)}}}, false)
|
||||
info := resultInfo(t, res)
|
||||
if info["status"] != "completed" || info["content_type"] != "audio/wav" {
|
||||
t.Fatalf("%v", info)
|
||||
}
|
||||
if _, _, file := call(t, h, secret, http.MethodGet, "/v1/files/"+info["file_id"].(string)+"/content", nil); file != string(wav) {
|
||||
t.Error("the file is not the song")
|
||||
}
|
||||
// A run refused says why, as the API did.
|
||||
res, _ = mcp(t, h, secret, "tools/call", map[string]any{"name": "run_workflow", "arguments": map[string]any{
|
||||
"model": "ace", "inputs": map[string]any{"cover": "data:image/png;base64," + testPicture(t)}}}, false)
|
||||
if res["isError"] != true || !strings.Contains(fmt.Sprint(res["content"]), "tags") {
|
||||
t.Errorf("a run missing a required input: %v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPicturesAreLookedAtByAVisionModelShrunkAndToASchema(t *testing.T) {
|
||||
e := newFakeEngine(t, "vlm")
|
||||
cfg := baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: []string{"vlm"}})
|
||||
vlm := catModel("vlm", nil)
|
||||
vlm.Capabilities.Vision = true
|
||||
cfg.Models = []catalog.Model{vlm}
|
||||
h := newTestRouter(t, cfg).Handler()
|
||||
|
||||
var big bytes.Buffer
|
||||
_ = png.Encode(&big, image.NewRGBA(image.Rect(0, 0, 3000, 1500)))
|
||||
res, _ := mcp(t, h, "", "tools/call", map[string]any{"name": "review_images", "arguments": map[string]any{
|
||||
"images": []string{base64.StdEncoding.EncodeToString(big.Bytes())},
|
||||
"question": "Does the render match the concept?",
|
||||
"json_schema": map[string]any{"type": "object", "properties": map[string]any{"match": map[string]any{"type": "boolean"}}},
|
||||
}}, false)
|
||||
if res["isError"] == true || !strings.Contains(fmt.Sprint(res["content"]), "hi") {
|
||||
t.Fatalf("%v", res)
|
||||
}
|
||||
var sent struct {
|
||||
ResponseFormat struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"response_format"`
|
||||
Messages []struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
ImageURL struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"image_url"`
|
||||
} `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(e.last.Load().([]byte), &sent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent.ResponseFormat.Type != "json_schema" || len(sent.Messages) != 1 || len(sent.Messages[0].Content) != 2 {
|
||||
t.Fatalf("sent %+v", sent)
|
||||
}
|
||||
b64, _ := strings.CutPrefix(sent.Messages[0].Content[1].ImageURL.URL, "data:image/png;base64,")
|
||||
pic, _ := base64.StdEncoding.DecodeString(b64)
|
||||
im, err := png.Decode(bytes.NewReader(pic))
|
||||
if err != nil || im.Bounds().Dx() != lookEdge || im.Bounds().Dy() != lookEdge/2 {
|
||||
t.Errorf("the picture sent is %v (%v), not shrunk to %d", im.Bounds(), err, lookEdge)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -120,7 +121,8 @@ func (r *Router) handlePutNote(w http.ResponseWriter, req *http.Request) {
|
||||
Text string `json:"text"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, req.Body, maxMemoryBody))
|
||||
// Pictures to look at come in the call, as base64.
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, req.Body, mcpBody))
|
||||
if err != nil || json.Unmarshal(raw, &body) != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error",
|
||||
`body must be JSON like {"text": "...", "tags": ["..."]}`)
|
||||
@@ -273,23 +275,66 @@ func (r *Router) handleMCP(w http.ResponseWriter, req *http.Request) {
|
||||
resp := rpcResponse{JSONRPC: "2.0", ID: rpc.ID}
|
||||
switch rpc.Method {
|
||||
case "initialize":
|
||||
resp.Result = map[string]any{
|
||||
"protocolVersion": mcpProtocolVer,
|
||||
result := map[string]any{
|
||||
"protocolVersion": mcpVersionFor(rpc.Params),
|
||||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||
"serverInfo": map[string]any{"name": "llmgw-memory", "version": "1"},
|
||||
"serverInfo": map[string]any{"name": "llmgw", "version": "1"},
|
||||
}
|
||||
if r.mediaModels().any() {
|
||||
result["instructions"] = mcpMediaInstructions
|
||||
}
|
||||
resp.Result = result
|
||||
case "ping":
|
||||
resp.Result = map[string]any{}
|
||||
case "tools/list":
|
||||
resp.Result = map[string]any{"tools": mcpTools}
|
||||
resp.Result = map[string]any{"tools": append(slices.Clone(mcpTools), r.mediaTools()...)}
|
||||
case "tools/call":
|
||||
resp.Result = r.callMemoryTool(req, rpc.Params)
|
||||
var call struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
Meta struct {
|
||||
ProgressToken json.RawMessage `json:"progressToken"`
|
||||
} `json:"_meta"`
|
||||
}
|
||||
_ = json.Unmarshal(rpc.Params, &call)
|
||||
if !isMediaTool(call.Name) {
|
||||
resp.Result = r.callMemoryTool(req, rpc.Params)
|
||||
break
|
||||
}
|
||||
if acceptsStream(req) {
|
||||
// Answered as a stream: how far along it is while it runs, which
|
||||
// a mesh's minutes want, then the answer.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
sw := &streamWriter{w: w, token: call.Meta.ProgressToken}
|
||||
stop := make(chan struct{})
|
||||
go sw.keepAlive(stop, 15*time.Second)
|
||||
resp.Result = r.callMediaTool(mediaCall{req: req, progress: sw.progress, streamed: true}, call.Name, call.Arguments)
|
||||
close(stop)
|
||||
sw.event(resp)
|
||||
return
|
||||
}
|
||||
resp.Result = r.callMediaTool(mediaCall{req: req, progress: func(float64, string) {}}, call.Name, call.Arguments)
|
||||
default:
|
||||
resp.Error = &rpcError{Code: -32601, Message: "method not found: " + rpc.Method}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// mcpVersionFor is the protocol version answered: the client's, of those
|
||||
// this server speaks, else its own.
|
||||
func mcpVersionFor(params json.RawMessage) string {
|
||||
var p struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
}
|
||||
_ = json.Unmarshal(params, &p)
|
||||
if slices.Contains([]string{"2025-03-26", "2025-06-18"}, p.ProtocolVersion) {
|
||||
return p.ProtocolVersion
|
||||
}
|
||||
return mcpProtocolVer
|
||||
}
|
||||
|
||||
func toolText(text string, isError bool) map[string]any {
|
||||
return map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": text}},
|
||||
|
||||
@@ -221,12 +221,16 @@ func glbWithLicense(glb []byte, lic catalog.License) ([]byte, error) {
|
||||
if extras == nil {
|
||||
extras = map[string]any{}
|
||||
}
|
||||
l := map[string]any{"name": lic.Name}
|
||||
if lic.URL != "" {
|
||||
l["url"] = lic.URL
|
||||
// The whole licence, as /v1/models gives it: its id, whether it allows
|
||||
// commercial use and where it grants nothing are what a pipeline that
|
||||
// sorts assets by licence reads, and the name alone is for people.
|
||||
var l map[string]any
|
||||
b, err := json.Marshal(lic)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if lic.Notice != "" {
|
||||
l["notice"] = lic.Notice
|
||||
if err := json.Unmarshal(b, &l); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extras["license"] = l
|
||||
asset["extras"] = extras
|
||||
|
||||
@@ -401,3 +401,34 @@ func TestAModelAsleepUntilAskedIsListedAndSaidAsleep(t *testing.T) {
|
||||
t.Error("a model serving is not said loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshCarriesItsWholeLicence(t *testing.T) {
|
||||
// The name alone was written: a pipeline that sorts assets by licence
|
||||
// reads the id, whether commercial use is allowed and where it is not.
|
||||
glb, err := objToGLB([]byte("v 0 0 0\nv 1 0 0\nv 1 1 0\nf 1 2 3\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
yes := true
|
||||
out, err := glbWithLicense(glb, catalog.License{ID: "stability-community", Name: "Stability AI Community License",
|
||||
Commercial: &yes, RevenueCapUSD: 1000000, Attribution: "Powered by Stability AI", ExcludedRegions: []string{"XX"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n := binary.LittleEndian.Uint32(out[12:])
|
||||
var doc struct {
|
||||
Asset struct {
|
||||
Extras struct {
|
||||
License map[string]any `json:"license"`
|
||||
} `json:"extras"`
|
||||
} `json:"asset"`
|
||||
}
|
||||
if err := json.Unmarshal(out[20:20+n], &doc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l := doc.Asset.Extras.License
|
||||
if l["id"] != "stability-community" || l["commercial"] != true || l["revenue_cap_usd"] != 1000000.0 ||
|
||||
l["attribution"] != "Powered by Stability AI" || l["name"] == nil {
|
||||
t.Errorf("licence %v", l)
|
||||
}
|
||||
}
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
// Anthropic's Messages API (/v1/messages), translated to a chat completion
|
||||
// on the way in and back on the way out, like the Responses API: so Claude
|
||||
// Code and Anthropic's SDKs reach the models here. Text, images, tools and
|
||||
// their results, the system prompt and streaming are translated; extended
|
||||
// thinking, prompt caching and server tools are not, and are ignored rather
|
||||
// than refused.
|
||||
// their results, the system prompt, streaming and extended thinking are
|
||||
// translated; prompt caching and server tools are not, and are ignored rather
|
||||
// than refused. Thinking asked for is a model's reasoning_content, given
|
||||
// back as thinking blocks before the text; not asked for, it is left out.
|
||||
|
||||
type msgRequest struct {
|
||||
Model string `json:"model"`
|
||||
@@ -34,8 +35,16 @@ type msgRequest struct {
|
||||
Metadata *struct {
|
||||
UserID string `json:"user_id"`
|
||||
} `json:"metadata"`
|
||||
// Thinking is extended thinking: "enabled" (with a budget the engines
|
||||
// here do not take) or "disabled".
|
||||
Thinking *struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"thinking"`
|
||||
}
|
||||
|
||||
// thinks is whether the request asked for thinking blocks.
|
||||
func (in msgRequest) thinks() bool { return in.Thinking != nil && in.Thinking.Type == "enabled" }
|
||||
|
||||
type msgMessage struct {
|
||||
Role string `json:"role"`
|
||||
Content json.RawMessage `json:"content"`
|
||||
@@ -197,6 +206,16 @@ func messagesToChat(in msgRequest) (map[string]any, error) {
|
||||
if in.Metadata != nil && in.Metadata.UserID != "" {
|
||||
out["user"] = in.Metadata.UserID
|
||||
}
|
||||
// A model that thinks or not as its chat template is told (Qwen3, and
|
||||
// the reasoning models served the same way) is told what was asked.
|
||||
if in.Thinking != nil {
|
||||
switch in.Thinking.Type {
|
||||
case "enabled":
|
||||
out["chat_template_kwargs"] = map[string]any{"enable_thinking": true}
|
||||
case "disabled":
|
||||
out["chat_template_kwargs"] = map[string]any{"enable_thinking": false}
|
||||
}
|
||||
}
|
||||
var tools []map[string]any
|
||||
for _, t := range in.Tools {
|
||||
if t.Type != "" && t.Type != "custom" {
|
||||
@@ -250,7 +269,7 @@ func (r *Router) handleMessages(w http.ResponseWriter, req *http.Request) {
|
||||
next.ContentLength = int64(len(upstream))
|
||||
next.Header.Set("Content-Type", "application/json")
|
||||
|
||||
t := &msgTranslator{w: w, model: in.Model, stream: in.Stream, id: newID("msg_")}
|
||||
t := &msgTranslator{w: w, model: in.Model, stream: in.Stream, id: newID("msg_"), thinking: in.thinks()}
|
||||
r.handleInference("/v1/chat/completions")(t, next)
|
||||
t.finish()
|
||||
}
|
||||
@@ -309,6 +328,11 @@ type msgTranslator struct {
|
||||
model string
|
||||
stream bool
|
||||
id string
|
||||
// thinking is whether the reasoning is given as thinking blocks.
|
||||
thinking bool
|
||||
// thinkIndex and thinkOpen are the thinking block being streamed.
|
||||
thinkIndex int
|
||||
thinkOpen bool
|
||||
|
||||
status int
|
||||
failed bool
|
||||
@@ -415,6 +439,20 @@ func stopReason(finish string) string {
|
||||
return "end_turn"
|
||||
}
|
||||
|
||||
// thinkingSignature stands where Anthropic signs a thinking block: the
|
||||
// thinking here comes from the gateway's own models, and nothing checks it,
|
||||
// but the SDKs expect the field.
|
||||
const thinkingSignature = "llmgw"
|
||||
|
||||
func (t *msgTranslator) closeThinking() {
|
||||
if t.thinkOpen {
|
||||
t.event("content_block_delta", map[string]any{"index": t.thinkIndex,
|
||||
"delta": map[string]any{"type": "signature_delta", "signature": thinkingSignature}})
|
||||
t.event("content_block_stop", map[string]any{"index": t.thinkIndex})
|
||||
t.thinkOpen = false
|
||||
}
|
||||
}
|
||||
|
||||
func (t *msgTranslator) closeText() {
|
||||
if t.textOpen {
|
||||
t.event("content_block_stop", map[string]any{"index": t.textIndex})
|
||||
@@ -433,8 +471,11 @@ func (t *msgTranslator) chatLine(line string) {
|
||||
var chunk struct {
|
||||
Choices []struct {
|
||||
Delta struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
Content string `json:"content"`
|
||||
// The reasoning, by either name the engines give it.
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ToolCalls []struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
@@ -461,7 +502,18 @@ func (t *msgTranslator) chatLine(line string) {
|
||||
if ch.FinishReason != "" {
|
||||
t.stopReason = stopReason(ch.FinishReason)
|
||||
}
|
||||
if r := ch.Delta.ReasoningContent + ch.Delta.Reasoning; r != "" && t.thinking {
|
||||
if !t.thinkOpen {
|
||||
t.thinkIndex, t.thinkOpen = t.nextBlock, true
|
||||
t.nextBlock++
|
||||
t.event("content_block_start", map[string]any{"index": t.thinkIndex,
|
||||
"content_block": map[string]any{"type": "thinking", "thinking": ""}})
|
||||
}
|
||||
t.event("content_block_delta", map[string]any{"index": t.thinkIndex,
|
||||
"delta": map[string]any{"type": "thinking_delta", "thinking": r}})
|
||||
}
|
||||
if d := ch.Delta.Content; d != "" {
|
||||
t.closeThinking()
|
||||
if !t.textOpen && t.text.Len() == 0 {
|
||||
t.textIndex, t.textOpen = t.nextBlock, true
|
||||
t.nextBlock++
|
||||
@@ -477,6 +529,7 @@ func (t *msgTranslator) chatLine(line string) {
|
||||
for _, tc := range ch.Delta.ToolCalls {
|
||||
c := t.byIndex[tc.Index]
|
||||
if c == nil {
|
||||
t.closeThinking()
|
||||
t.closeText()
|
||||
for _, o := range t.calls {
|
||||
if o.open {
|
||||
@@ -532,6 +585,7 @@ func (t *msgTranslator) finish() {
|
||||
t.chatLine(rest)
|
||||
}
|
||||
t.begin()
|
||||
t.closeThinking()
|
||||
t.closeText()
|
||||
for _, c := range t.calls {
|
||||
if c.open {
|
||||
@@ -553,8 +607,10 @@ func (t *msgTranslator) fromChatJSON(b []byte) {
|
||||
var c struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
Reasoning string `json:"reasoning"`
|
||||
ToolCalls []struct {
|
||||
ID string `json:"id"`
|
||||
Function struct {
|
||||
Name string `json:"name"`
|
||||
@@ -578,6 +634,9 @@ func (t *msgTranslator) fromChatJSON(b []byte) {
|
||||
if len(c.Choices) > 0 {
|
||||
ch := c.Choices[0]
|
||||
reason = stopReason(ch.FinishReason)
|
||||
if r := ch.Message.ReasoningContent + ch.Message.Reasoning; r != "" && t.thinking {
|
||||
content = append(content, map[string]any{"type": "thinking", "thinking": r, "signature": thinkingSignature})
|
||||
}
|
||||
if ch.Message.Content != "" {
|
||||
content = append(content, map[string]any{"type": "text", "text": ch.Message.Content})
|
||||
}
|
||||
|
||||
@@ -139,3 +139,65 @@ func TestAnAnthropicClientsKeyIsAccepted(t *testing.T) {
|
||||
t.Errorf("%d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestThinkingAskedForComesBackAsThinkingBlocks(t *testing.T) {
|
||||
// A reasoning model's thoughts were dropped: Claude Code asking for
|
||||
// extended thinking got the answer alone.
|
||||
var in msgRequest
|
||||
_ = json.Unmarshal([]byte(`{"model":"m","max_tokens":10,"thinking":{"type":"enabled","budget_tokens":2048},
|
||||
"messages":[{"role":"user","content":"hi"}]}`), &in)
|
||||
chat, _ := messagesToChat(in)
|
||||
if b, _ := json.Marshal(chat); !strings.Contains(string(b), `"chat_template_kwargs":{"enable_thinking":true}`) {
|
||||
t.Errorf("the model was not told to think: %s", b)
|
||||
}
|
||||
|
||||
whole := `{"choices":[{"message":{"reasoning_content":"two plus two","content":"4"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":3,"completion_tokens":5}}`
|
||||
_, h := messagesRouter(t, whole)
|
||||
body := map[string]any{"model": "m", "max_tokens": 10, "thinking": map[string]any{"type": "enabled", "budget_tokens": 1024},
|
||||
"messages": []any{map[string]any{"role": "user", "content": "2+2?"}}}
|
||||
var out struct {
|
||||
Content []map[string]any `json:"content"`
|
||||
}
|
||||
_ = json.Unmarshal(sendMessages(t, h, body).Body.Bytes(), &out)
|
||||
if len(out.Content) != 2 || out.Content[0]["type"] != "thinking" || out.Content[0]["thinking"] != "two plus two" ||
|
||||
out.Content[0]["signature"] == nil || out.Content[1]["text"] != "4" {
|
||||
t.Errorf("content %v", out.Content)
|
||||
}
|
||||
// Not asked for, it is left out, as before.
|
||||
delete(body, "thinking")
|
||||
_ = json.Unmarshal(sendMessages(t, h, body).Body.Bytes(), &out)
|
||||
if len(out.Content) != 1 || out.Content[0]["type"] != "text" {
|
||||
t.Errorf("unasked, content %v", out.Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStreamedThinkingIsItsOwnBlock(t *testing.T) {
|
||||
stream := "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"two \"}}]}\n\n" +
|
||||
"data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"plus two\"}}]}\n\n" +
|
||||
"data: {\"choices\":[{\"delta\":{\"content\":\"4\"},\"finish_reason\":\"stop\"}]}\n\n" +
|
||||
"data: [DONE]\n\n"
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/chat/completions" {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
_, _ = w.Write([]byte(stream))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
rt := newTestRouter(t, baseConfig(config.Endpoint{ID: "a", URL: srv.URL, Models: []string{"m"}}))
|
||||
rec := sendMessages(t, rt.Handler(), map[string]any{"model": "m", "max_tokens": 10, "stream": true,
|
||||
"thinking": map[string]any{"type": "enabled", "budget_tokens": 1024},
|
||||
"messages": []any{map[string]any{"role": "user", "content": "2+2?"}}})
|
||||
got := rec.Body.String()
|
||||
for _, s := range []string{`"content_block":{"thinking":"","type":"thinking"},"index":0`,
|
||||
`"delta":{"thinking":"two ","type":"thinking_delta"}`, `"type":"signature_delta"`,
|
||||
`"content_block":{"text":"","type":"text"},"index":1`, `"text":"4","type":"text_delta"`} {
|
||||
if !strings.Contains(got, s) {
|
||||
t.Errorf("lacks %s in\n%s", s, got)
|
||||
}
|
||||
}
|
||||
if strings.Index(got, "signature_delta") > strings.Index(got, `"text_delta"`) {
|
||||
t.Error("the thinking block was not closed before the text began")
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,10 @@ type Router struct {
|
||||
cfg config.Router
|
||||
log *slog.Logger
|
||||
|
||||
// selfHandler is the client API as Handler built it, key check and all:
|
||||
// what the MCP tools make their requests through, as the caller.
|
||||
selfHandler atomic.Pointer[http.Handler]
|
||||
|
||||
reg *endpoint.Registry
|
||||
poller *scrape.Poller
|
||||
px *proxy.Proxy
|
||||
|
||||
@@ -27,6 +27,7 @@ var copyOrder = []string{
|
||||
"memberships",
|
||||
// After users, which it points at.
|
||||
"user_excluded_licences",
|
||||
"preset_state",
|
||||
"api_keys",
|
||||
"key_claims",
|
||||
"sessions",
|
||||
|
||||
+17
-3
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -193,13 +194,26 @@ func (s *DB) AllJobs(ctx context.Context, status string, limit int) ([]Job, erro
|
||||
|
||||
// ClaimJob takes the lease on the oldest job with work left that no gateway
|
||||
// holds, for owner until the time given. ok is false when there is none.
|
||||
func (s *DB) ClaimJob(ctx context.Context, owner string, now, until time.Time) (Job, bool, error) {
|
||||
//
|
||||
// Jobs for the models in skip are passed over: a worker running as many of a
|
||||
// model's jobs as it will at once takes the next job for another model
|
||||
// rather than waiting behind it.
|
||||
func (s *DB) ClaimJob(ctx context.Context, owner string, now, until time.Time, skip ...string) (Job, bool, error) {
|
||||
var id string
|
||||
args := []any{owner, millis(until), JobQueued, JobRunning, JobCancelling, millis(now)}
|
||||
notIn := ""
|
||||
if len(skip) > 0 {
|
||||
notIn = " AND model NOT IN (?" + strings.Repeat(", ?", len(skip)-1) + ")"
|
||||
for _, m := range skip {
|
||||
args = append(args, m)
|
||||
}
|
||||
}
|
||||
args = append(args, millis(now))
|
||||
err := s.db.QueryRowContext(ctx, `UPDATE jobs SET lease_owner = ?, lease_until = ?
|
||||
WHERE id = (SELECT id FROM jobs WHERE status IN (?, ?, ?) AND lease_until < ?
|
||||
WHERE id = (SELECT id FROM jobs WHERE status IN (?, ?, ?) AND lease_until < ?`+notIn+`
|
||||
ORDER BY created_at LIMIT 1)
|
||||
AND lease_until < ?
|
||||
RETURNING id`, owner, millis(until), JobQueued, JobRunning, JobCancelling, millis(now), millis(now)).Scan(&id)
|
||||
RETURNING id`, args...).Scan(&id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Job{}, false, nil
|
||||
}
|
||||
|
||||
@@ -120,3 +120,24 @@ func TestAnOperatorSeesEveryOwnersJobs(t *testing.T) {
|
||||
t.Errorf("%d queued", len(queued))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAClaimPassesOverTheModelsItIsToldTo(t *testing.T) {
|
||||
db := openDB(t)
|
||||
ctx := t.Context()
|
||||
now := time.Now()
|
||||
for i, m := range []string{"sf3d", "flux"} {
|
||||
at := now.Add(time.Duration(i) * time.Second)
|
||||
if err := db.PutJob(ctx, Job{ID: "job_" + m, Owner: "u", KeyID: "k", Endpoint: "/e", Model: m, Input: []byte("{}"),
|
||||
Status: JobQueued, CreatedAt: at, ExpiresAt: at.Add(time.Hour)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
j, ok, err := db.ClaimJob(ctx, "w", now, now.Add(time.Minute), "sf3d")
|
||||
if err != nil || !ok || j.Model != "flux" {
|
||||
t.Errorf("claimed %+v %v %v; sf3d was to be passed over", j.ID, ok, err)
|
||||
}
|
||||
j, ok, _ = db.ClaimJob(ctx, "w", now, now.Add(time.Minute))
|
||||
if !ok || j.Model != "sf3d" {
|
||||
t.Errorf("then %+v %v", j.ID, ok)
|
||||
}
|
||||
}
|
||||
@@ -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,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)
|
||||
}
|
||||
}
|
||||
@@ -239,14 +239,14 @@ func TestThePageReloadsOnlyWhileSomethingIsDownloading(t *testing.T) {
|
||||
|
||||
// A reload while nothing is happening would take with it whatever the
|
||||
// operator was typing into the fetch form.
|
||||
if strings.Contains(idle, "http-equiv=\"refresh\"") {
|
||||
if strings.Contains(idle, "name=\"llmgw-refresh\"") {
|
||||
t.Error("an idle page should not reload itself")
|
||||
}
|
||||
|
||||
busy := mirrorGet(t, mirrorPage(t, &fakeMirror{models: []mirror.Status{
|
||||
{Model: "m", Version: "v", File: "f", State: mirror.Fetching},
|
||||
}}, true), "/admin/mirror").Body.String()
|
||||
if !strings.Contains(busy, "http-equiv=\"refresh\"") {
|
||||
if !strings.Contains(busy, "name=\"llmgw-refresh\"") {
|
||||
t.Error("a page with a download running should follow it")
|
||||
}
|
||||
}
|
||||
@@ -680,7 +680,7 @@ func TestEachTabDoesOnlyItsOwnWork(t *testing.T) {
|
||||
if strings.Contains(held, `name="source" required`) || strings.Contains(held, "Search Hugging Face") {
|
||||
t.Error("other tabs' forms are on the list")
|
||||
}
|
||||
if !strings.Contains(held, `http-equiv="refresh"`) {
|
||||
if !strings.Contains(held, `name="llmgw-refresh"`) {
|
||||
t.Error("the list stopped following a download")
|
||||
}
|
||||
|
||||
@@ -688,7 +688,7 @@ func TestEachTabDoesOnlyItsOwnWork(t *testing.T) {
|
||||
if reg.explored != 1 || !strings.Contains(explore, "Search Hugging Face") {
|
||||
t.Error("the explorer tab did not explore")
|
||||
}
|
||||
if strings.Contains(explore, `id="held"`) || strings.Contains(explore, `http-equiv="refresh"`) {
|
||||
if strings.Contains(explore, `id="held"`) || strings.Contains(explore, `name="llmgw-refresh"`) {
|
||||
t.Error("the explorer shows the list, or reloads away a search")
|
||||
}
|
||||
// A link from before there were tabs still lands on the explorer.
|
||||
|
||||
@@ -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/`))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -196,5 +196,31 @@
|
||||
"dash.usage.none": "No requests recorded in the last 24 hours.",
|
||||
"dash.usage.auth_off": "Authentication is off, so there is no identity to account against.",
|
||||
|
||||
"dash.admin_listener": "Metrics, pprof and the management API are on the admin listener, which is bound to loopback and not reachable from here. pprof can dump the process heap, where API keys sit in plaintext, which is why that port is not exposed:"
|
||||
"dash.admin_listener": "Metrics, pprof and the management API are on the admin listener, which is bound to loopback and not reachable from here. pprof can dump the process heap, where API keys sit in plaintext, which is why that port is not exposed:",
|
||||
|
||||
"dash.presets.title": "Pictures and 3D models",
|
||||
"dash.presets.about": "Models that come with llmgw, their workflow, nodes and licence already tried. Adding one also has the GPU nodes install ComfyUI, about 10 GB each, when the cluster has none.",
|
||||
"dash.presets.makes": "Makes",
|
||||
"dash.presets.makes.pictures": "pictures",
|
||||
"dash.presets.makes.meshes": "3D models",
|
||||
"dash.presets.card": "Needs",
|
||||
"dash.presets.licence": "Licence",
|
||||
"dash.presets.not_commercial": "%s, not for commercial use",
|
||||
"dash.presets.fits": "Fits",
|
||||
"dash.presets.fits_none": "no card here",
|
||||
"dash.presets.state": "Here",
|
||||
"dash.presets.added_auto": "added automatically, %s",
|
||||
"dash.presets.added_by": "added by %s, %s",
|
||||
"dash.presets.in_catalog": "in the catalog",
|
||||
"dash.presets.declined": "removed, so not added again by itself",
|
||||
"dash.presets.add": "Add",
|
||||
"dash.presets.add_again": "Add again",
|
||||
"dash.presets.auto_on": "Added by themselves: when a card fits one and the catalog has nothing that makes what it makes.",
|
||||
"dash.presets.auto_off": "Not added by themselves: only when somebody adds one.",
|
||||
"dash.presets.turn_off": "Stop adding them",
|
||||
"dash.presets.turn_on": "Add them by themselves",
|
||||
"dash.presets.notice.added": "%s added; it is placed on the next pass, and loads when first asked for",
|
||||
"dash.presets.notice.auto_on": "Pictures and 3D models are added by themselves where a card fits",
|
||||
"dash.presets.notice.auto_off": "Pictures and 3D models are no longer added by themselves",
|
||||
"dash.presets.adds_comfyui": "Adding it installs ComfyUI on the GPU nodes, about 10 GB each, and restarts the control planes, standbys first"
|
||||
}
|
||||
@@ -194,5 +194,31 @@
|
||||
"dash.usage.none": "Son 24 saatte hiç istek kaydedilmedi.",
|
||||
"dash.usage.auth_off": "Kimlik doğrulama kapalı; bu yüzden kullanımın yazılacağı bir kimlik yok.",
|
||||
|
||||
"dash.admin_listener": "Metrikler, pprof ve yönetim API'si yönetici dinleyicisindedir; bu dinleyici loopback'e bağlıdır ve buradan erişilemez. pprof, API anahtarlarının düz metin olarak durduğu süreç belleğinin dökümünü alabilir; o portun dışarı açılmamasının nedeni budur:"
|
||||
"dash.admin_listener": "Metrikler, pprof ve yönetim API'si yönetici dinleyicisindedir; bu dinleyici loopback'e bağlıdır ve buradan erişilemez. pprof, API anahtarlarının düz metin olarak durduğu süreç belleğinin dökümünü alabilir; o portun dışarı açılmamasının nedeni budur:",
|
||||
|
||||
"dash.presets.title": "Resim ve 3D modelleri",
|
||||
"dash.presets.about": "llmgw ile gelen modeller; iş akışları, düğümleri ve lisansları denenmiş. Birini eklemek, kümede ComfyUI yoksa GPU düğümlerine onu da kurdurur (her birine yaklaşık 10 GB).",
|
||||
"dash.presets.makes": "Ürettiği",
|
||||
"dash.presets.makes.pictures": "resim",
|
||||
"dash.presets.makes.meshes": "3D model",
|
||||
"dash.presets.card": "Gerekli kart",
|
||||
"dash.presets.licence": "Lisans",
|
||||
"dash.presets.not_commercial": "%s, ticari kullanım yok",
|
||||
"dash.presets.fits": "Uyan sınıflar",
|
||||
"dash.presets.fits_none": "burada uyan kart yok",
|
||||
"dash.presets.state": "Durum",
|
||||
"dash.presets.added_auto": "kendiliğinden eklendi, %s",
|
||||
"dash.presets.added_by": "%s ekledi, %s",
|
||||
"dash.presets.in_catalog": "katalogda",
|
||||
"dash.presets.declined": "kaldırıldı; kendiliğinden yeniden eklenmez",
|
||||
"dash.presets.add": "Ekle",
|
||||
"dash.presets.add_again": "Yeniden ekle",
|
||||
"dash.presets.auto_on": "Kendiliğinden ekleniyorlar: bir kart uyduğunda ve katalogda aynı işi yapan model olmadığında.",
|
||||
"dash.presets.auto_off": "Kendiliğinden eklenmiyorlar: yalnızca biri eklediğinde.",
|
||||
"dash.presets.turn_off": "Kendiliğinden eklemeyi durdur",
|
||||
"dash.presets.turn_on": "Kendiliğinden ekle",
|
||||
"dash.presets.notice.added": "%s eklendi; sonraki turda yerleştirilir, ilk istendiğinde yüklenir",
|
||||
"dash.presets.notice.auto_on": "Resim ve 3D modelleri, kart uyan yerde kendiliğinden eklenecek",
|
||||
"dash.presets.notice.auto_off": "Resim ve 3D modelleri artık kendiliğinden eklenmeyecek",
|
||||
"dash.presets.adds_comfyui": "Eklemek GPU düğümlerine ComfyUI kurar (her birine yaklaşık 10 GB) ve kontrol düzlemlerini yeniden başlatır, önce yedekler"
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func TestAHeldModelOpensToWhereItRunsAndHowMuchRoomIsLeft(t *testing.T) {
|
||||
if strings.Contains(body, "secret-engine-key") {
|
||||
t.Error("the engine's key reached the page")
|
||||
}
|
||||
if strings.Contains(body, `http-equiv="refresh"`) {
|
||||
if strings.Contains(body, `name="llmgw-refresh"`) {
|
||||
t.Error("a page with an open form reloads under the operator")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,50 @@
|
||||
var msg = e.target.getAttribute && e.target.getAttribute('data-confirm');
|
||||
if (msg && !window.confirm(msg)) { e.preventDefault(); }
|
||||
});
|
||||
// A form being filled in is not refreshed away. The live parts of the
|
||||
// cluster and node pages are swapped whole every few seconds, and a form
|
||||
// inside one lost what was typed into it, its open choice, its focus:
|
||||
// filling one in raced the clock. While a field in the refreshing part
|
||||
// has the focus, or a form in it has been typed into and not sent, it
|
||||
// waits; sending the form, or leaving the page, ends that.
|
||||
var edited = [];
|
||||
document.addEventListener('input', function (e) {
|
||||
var f = e.target.form || (e.target.closest && e.target.closest('form'));
|
||||
if (f && edited.indexOf(f) < 0) { edited.push(f); }
|
||||
});
|
||||
document.addEventListener('submit', function (e) {
|
||||
if (!e.defaultPrevented) { edited = []; }
|
||||
});
|
||||
function busy(root) {
|
||||
var a = document.activeElement;
|
||||
if (a && a !== document.body && root.contains(a) &&
|
||||
a.matches('input, select, textarea, [contenteditable]')) {
|
||||
return true;
|
||||
}
|
||||
for (var i = 0; i < edited.length; i++) {
|
||||
if (root.contains(edited[i]) && document.contains(edited[i])) { return true; }
|
||||
}
|
||||
return false;
|
||||
}
|
||||
// htmx's trigger filters are evaluated JavaScript; this is the same
|
||||
// "only while visible" without them.
|
||||
// "only while visible" without them, and "not while being filled in".
|
||||
document.addEventListener('htmx:beforeRequest', function (e) {
|
||||
if (document.visibilityState !== 'visible' && e.detail.elt.hasAttribute('data-visible-only')) {
|
||||
var elt = e.detail.elt;
|
||||
if (elt.hasAttribute('data-visible-only') &&
|
||||
(document.visibilityState !== 'visible' || busy(elt))) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
// A page that reloads itself (<meta name="llmgw-refresh" content="N">)
|
||||
// does it on the same terms, rather than by http-equiv, which nothing
|
||||
// can hold off.
|
||||
var r = document.querySelector('meta[name="llmgw-refresh"]');
|
||||
var every = r ? parseInt(r.getAttribute('content'), 10) : 0;
|
||||
if (every > 0) {
|
||||
setInterval(function () {
|
||||
if (document.visibilityState === 'visible' && !busy(document.body)) {
|
||||
window.location.reload();
|
||||
}
|
||||
}, every * 1000);
|
||||
}
|
||||
})();
|
||||
@@ -1,10 +1,10 @@
|
||||
{{define "admin_mirror"}}<!doctype html>
|
||||
<html lang="{{lang}}"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
{{/* Only while something is downloading. A reload at any other time would
|
||||
take with it whatever the operator was typing into the form below. */}}
|
||||
{{if .RefreshSec}}<meta http-equiv="refresh" content="{{.RefreshSec}}">{{end}}
|
||||
<script defer src="/_static/pages.js"></script>
|
||||
{{/* Only while something is downloading, and not while a form is being
|
||||
filled in: pages.js holds the reload off then. */}}
|
||||
{{if .RefreshSec}}<meta name="llmgw-refresh" content="{{.RefreshSec}}">{{end}}
|
||||
<script defer src="{{asset "pages.js"}}"></script>
|
||||
<title>{{t "mirror.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<link rel="stylesheet" href="/_static/katex/katex.min.css">
|
||||
<script defer src="/_static/katex/katex.min.js"></script>
|
||||
<script defer src="/_static/katex/auto-render.min.js"></script>
|
||||
<script defer src="/_static/pages.js"></script>
|
||||
<script defer src="{{asset "pages.js"}}"></script>
|
||||
<meta name="csrf" content="{{.CSRF}}">
|
||||
<meta name="chat-owner" content="{{.User.ID}}">
|
||||
</head><body><div class="wrap">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<noscript>{{.RefreshMeta}}</noscript>
|
||||
<meta name="htmx-config" content='{"allowEval":false}'>
|
||||
<script src="/_static/htmx-2.0.11.min.js"></script>
|
||||
<script defer src="/_static/pages.js"></script>
|
||||
<script defer src="{{asset "pages.js"}}"></script>
|
||||
<title>llmgw — {{t "dash.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
@@ -163,6 +163,34 @@ document.querySelectorAll("form.build-choice select").forEach(function (s) {
|
||||
{{end}}</table>
|
||||
{{else}}<p class="empty">{{t "dash.models.empty"}}</p>{{end}}
|
||||
|
||||
{{with .Presets}}
|
||||
<h3 id="presets">{{t "dash.presets.title"}}</h3>
|
||||
<p class="note">{{t "dash.presets.about"}}</p>
|
||||
<table class="rows"><tr><th>{{t "dash.col.model"}}</th><th>{{t "dash.presets.makes"}}</th><th>{{t "dash.presets.card"}}</th><th>{{t "dash.presets.licence"}}</th><th>{{t "dash.presets.fits"}}</th><th>{{t "dash.presets.state"}}</th><th></th></tr>
|
||||
{{range .Rows}}<tr>
|
||||
<td><code>{{.Name}}</code></td>
|
||||
<td title="{{.Description}}">{{.Makes}}</td>
|
||||
<td>{{.Card}}</td>
|
||||
<td>{{.Licence}}</td>
|
||||
<td>{{or .Fits (t "dash.presets.fits_none")}}</td>
|
||||
{{/* Why the leader does not add it itself is the control plane's, in English. */}}
|
||||
<td{{if .Declined}} class="note"{{end}}>{{or .State "—"}}{{with .Blocked}} <span class="note">({{.}})</span>{{end}}</td>
|
||||
<td>{{if and $.Menu.CSRF (not .InCatalog)}}
|
||||
<form method="post" action="/admin/presets/{{.Name}}" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.Menu.CSRF}}">
|
||||
<button type="submit"{{if .AddsComfyUI}} title="{{t "dash.presets.adds_comfyui"}}"{{end}}>{{if .Declined}}{{t "dash.presets.add_again"}}{{else}}{{t "dash.presets.add"}}{{end}}</button>
|
||||
</form>{{if .AddsComfyUI}} <span class="warn">{{t "dash.presets.adds_comfyui"}}</span>{{end}}{{end}}</td>
|
||||
</tr>
|
||||
{{end}}</table>
|
||||
<p>{{if .Auto}}{{t "dash.presets.auto_on"}}{{else}}{{t "dash.presets.auto_off"}}{{end}}
|
||||
{{if $.Menu.CSRF}}
|
||||
<form method="post" action="/admin/presets/auto" class="inline">
|
||||
<input type="hidden" name="csrf" value="{{$.Menu.CSRF}}">
|
||||
<input type="hidden" name="on" value="{{if .Auto}}false{{else}}true{{end}}">
|
||||
<button type="submit">{{if .Auto}}{{t "dash.presets.turn_off"}}{{else}}{{t "dash.presets.turn_on"}}{{end}}</button>
|
||||
</form>{{end}}</p>
|
||||
{{end}}
|
||||
|
||||
{{if .Unplaced}}
|
||||
<h2>{{t "dash.unplaced"}}</h2>
|
||||
{{/* The scheduler's reasons, as it gives them. */}}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<html lang="{{lang}}"><head>
|
||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>{{t "images.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
<script defer src="/_static/pages.js"></script>
|
||||
<script defer src="{{asset "pages.js"}}"></script>
|
||||
<meta name="csrf" content="{{.CSRF}}">
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<noscript>{{.RefreshMeta}}</noscript>
|
||||
<meta name="htmx-config" content='{"allowEval":false}'>
|
||||
<script src="/_static/htmx-2.0.11.min.js"></script>
|
||||
<script defer src="/_static/pages.js"></script>
|
||||
<script defer src="{{asset "pages.js"}}"></script>
|
||||
<title>llmgw — {{.Node.ID}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||
</head><body><div class="wrap">
|
||||
<header>
|
||||
|
||||
Reference in new issue
Block a user