Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edeeb9f8e1 | ||
|
|
c22528c26e | ||
|
|
bda988b78f | ||
|
|
a7cb3aac3c | ||
|
|
d1e91868ee | ||
|
|
aa531b1053 | ||
|
|
55d5e13534 | ||
|
|
7c53380e68 | ||
|
|
0460f52dd2 | ||
|
|
efffdb66b7 | ||
|
|
f5eb6963af | ||
|
|
1e7086e13e | ||
|
|
a9a2360f44 | ||
|
|
e78075876b |
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`.
|
||||
|
||||
@@ -26,6 +26,80 @@ APIs change between minors without a deprecation period.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
|
||||
- **Pictures uploaded to work from**: `/v1/files` takes `purpose=vision` or
|
||||
`user_data` besides `batch`, kept as long as job results, so a picture is
|
||||
sent once and named by its `file-…` id in the MCP tools and a workflow's
|
||||
inputs.
|
||||
- **One ComfyUI for an environment's models**: ComfyUI models in one
|
||||
`comfyui.environment` (two or more) share one replica — the catalog
|
||||
makes it a hidden entry, `comfyui-env-<name>`, on the first member's
|
||||
profiles with the most memory any member needs, carrying every member's
|
||||
weights and custom nodes. Asking for any member starts it; it answers for
|
||||
each with that member's workflow and checkpoint, and the members get no
|
||||
replicas of their own. A picture model and a 3D model on one card stop
|
||||
handing it to each other with a cold start each way.
|
||||
- **Pictures, 3D models, vision and workflows as MCP tools** at `/mcp`,
|
||||
beside the memory notes, each offered only where the gateway has a model
|
||||
for it: `generate_image` (with `concept`, `side` and `texture` styles),
|
||||
`generate_mesh` (from a picture or a prompt), `review_images` (a vision
|
||||
model, to a JSON schema), `run_workflow`, `job_status` and
|
||||
`gateway_status`. Each makes its request under the caller's key, so
|
||||
allowlists, limits, licences and budgets apply and it is charged like
|
||||
any request; what it makes is kept as the caller's file and answered by
|
||||
id and download URL. A client that takes a stream gets
|
||||
`notifications/progress` while a mesh is made and waits up to ten
|
||||
minutes; one that does not, 50 seconds, then the job id.
|
||||
- **Setup asks where alerts go**: `llmgw server init --webhook URL`
|
||||
(repeatable) and a **Notification webhooks** field on both setup screens.
|
||||
Slack's, Mattermost's and Rocket.Chat's addresses are sent Slack's text,
|
||||
any other the event as JSON (`slack:` or `json:` in front says which).
|
||||
They are added to the cluster's `notify.webhooks` beside those already
|
||||
there, and each is sent a test once the control plane runs on them.
|
||||
|
||||
- **Extended thinking on `/v1/messages`**: `thinking: {type: "enabled"}`
|
||||
tells a model that thinks by its chat template to (`enable_thinking`),
|
||||
and its reasoning comes back as `thinking` blocks before the text,
|
||||
streamed as `thinking_delta` events; `disabled` tells it not to. Not
|
||||
asked for, the reasoning is left out as before.
|
||||
- **How long on-demand models hold a card** is a setting, in force on the
|
||||
leader within seconds: `on_demand.evict_after_idle` (2m unless set) and
|
||||
`on_demand.min_lifetime` (5m). On one card shared by a picture model and
|
||||
a 3D model, 1m each brings the wait between a picture and its mesh to
|
||||
about a minute.
|
||||
|
||||
### Fixed
|
||||
|
||||
- A model whose weights and context pool did not fit its card was added
|
||||
and then failed to make its context on every start, taking the card from
|
||||
others each time (Apertus 70B with a 65536-token pool, 8B with 131072, on
|
||||
24 GB). `ctl model serve` shrinks the pool to what the card holds, saying
|
||||
so, and refuses weights that do not fit at all; the portal's serve form
|
||||
refuses such a pool and says the largest that fits.
|
||||
- Removing weights from the mirror that a catalog model still loads is
|
||||
refused, naming the model: removed from the mirror alone, the model stayed
|
||||
in the catalog and its replicas went on loading the node's copy.
|
||||
- Jobs ran one at a time, so a 3D job waiting for the card held up another
|
||||
model's job that could have run. They run side by side, one per model at
|
||||
a time and eight in all, on the leader as before.
|
||||
- `llmgw ctl model serve` served an embedding model as a chat model: no
|
||||
`--embedding`, four slots, a 512-token batch, `embeddings` unset and a
|
||||
"say hello" smoke test it could not pass. A model whose GGUF says it
|
||||
pools (or `--embeddings`) is served for embedding: 32 slots of 1024
|
||||
tokens unless sized otherwise, a batch that holds a whole passage, and
|
||||
one vector asked for as its smoke test.
|
||||
- `llmgw ctl upgrade` checks every node's version once the waves are done
|
||||
and fails naming any not on the release, and says when the control plane
|
||||
has no nodes registered; `llmgw upgrade` on a control plane says how many
|
||||
nodes it left behind and the command that upgrades them. A site's nodes
|
||||
stayed a release behind with nothing to say so.
|
||||
- The cluster and node pages swapped their live part every few seconds,
|
||||
forms and all, so filling one in raced the clock: what was typed, an
|
||||
open choice and the focus were lost. The refresh now waits while a field
|
||||
in it has the focus or a form in it has been typed into and not sent;
|
||||
the mirror page's reload during a download waits the same way.
|
||||
|
||||
## v0.14.5 — 2026-10-14
|
||||
|
||||
Picture and 3D models that add themselves where a card fits them, with a
|
||||
|
||||
@@ -9292,6 +9292,10 @@
|
||||
"origin": {
|
||||
"$ref": "#/components/schemas/catalog.Origin",
|
||||
"description": "Origin is the registry repository the weights came from, at which commit, and each file's checksum there. Written when a model is served from the registry; for one that was not, worked out from the mirror's record of where it fetched the file. It is what lets the control plane say that the repository has published different weights since, and move the model to them."
|
||||
},
|
||||
"shared": {
|
||||
"$ref": "#/components/schemas/catalog.SharedComfy",
|
||||
"description": "Shared is set on an environment's hidden entry only: the members its replica answers for. Made by the catalog, never written."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -9545,6 +9549,51 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"catalog.SharedComfy": {
|
||||
"type": "object",
|
||||
"description": "SharedComfy is what an environment's entry carries of its members.",
|
||||
"properties": {
|
||||
"environment": {
|
||||
"type": "string"
|
||||
},
|
||||
"members": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/catalog.SharedMember"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"catalog.SharedMember": {
|
||||
"type": "object",
|
||||
"description": "SharedMember is one model a shared replica answers for.",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"aliases": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"type": "string",
|
||||
"description": "Source is the member's weights, which the replica's node fetches."
|
||||
},
|
||||
"hf_repo": {
|
||||
"type": "string",
|
||||
"description": "HFRepo is the repository its node loads the weights by name from."
|
||||
},
|
||||
"workflow": {
|
||||
"type": "string",
|
||||
"description": "Workflow and Checkpoint are what a request for it is sent."
|
||||
},
|
||||
"checkpoint": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"eval.Live": {
|
||||
"type": "object",
|
||||
"description": "Live is a run as it stands: one running, with how many of its cases are done, or one finished.",
|
||||
@@ -10413,6 +10462,10 @@
|
||||
"type": "boolean",
|
||||
"description": "Tools says the model makes tool calls the engine returns as calls: it is written into the catalog, where clients and editors read it. Check a running replica with llmgw ctl tool-check before saying so."
|
||||
},
|
||||
"embeddings": {
|
||||
"type": "boolean",
|
||||
"description": "Embeddings serves it as an embedding model, which its GGUF header says by itself for most: --embedding, many slots, a batch that holds a whole passage, /v1/embeddings."
|
||||
},
|
||||
"cpu_cores": {
|
||||
"type": "integer",
|
||||
"description": "CPUCores is what a replica computes on; zero takes half the smallest node it fits on."
|
||||
|
||||
@@ -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,51 @@ curl https://llm.example.com/mcp \
|
||||
-d '{"jsonrpc": "2.0", "id": 1, "method": "tools/list"}'
|
||||
```
|
||||
|
||||
**Pictures, 3D models, vision and workflows.** Where the gateway has the
|
||||
models for them, the same server offers more tools — only those it has a
|
||||
model for:
|
||||
|
||||
| Tool | Does |
|
||||
|---|---|
|
||||
| `generate_image` | A picture from a prompt. `style` adds what a picture for a mesh wants: `concept` (one object, three-quarter view, grey background), `side` (an exact side view), `texture` (a seamless tile) or `none`. Answers the PNG's `file_id`, `url` and `seed`, with a 512 px preview to look at. |
|
||||
| `generate_mesh` | A GLB from a picture — `image` is a `file-…` id (a `generate_image` result, say), a data URL or base64 — or from a `prompt`, for which a concept picture is made first. `seed`, `resolution`, `texture_resolution`, and any other input the model's workflow declares in `inputs`. Answers the GLB's `file_id`, `url` and licence. |
|
||||
| `review_images` | Asks a vision model about one to four pictures (each shrunk to 1280 px on its long side); `json_schema` makes it answer that JSON. |
|
||||
| `run_workflow` | Runs a ComfyUI workflow from the inputs its catalog entry declares, and answers its result file. |
|
||||
| `job_status` | Where a mesh or a workflow run stands, and its file when done; `wait_seconds` waits for it. |
|
||||
| `gateway_status` | The picture, 3D, vision and workflow models, whether each is loaded on a GPU now, a workflow's inputs, and your last jobs. |
|
||||
|
||||
Each tool makes its request of the gateway under your key, so your
|
||||
allowlist, limits, left-out licences and budget apply, and it is charged
|
||||
and listed like any other request. What it makes is kept as your file for
|
||||
as long as job results are: fetch it from `url` with the same key (`curl -H
|
||||
"Authorization: Bearer $LLMGW_API_KEY" -o rock.glb <url>`). A tool server
|
||||
over HTTP cannot write to your disk, so an agent that needs the file
|
||||
locally — to import it in Blender — fetches it, or goes through a small
|
||||
local bridge that does.
|
||||
|
||||
A picture can be uploaded once and named by its id after: `POST /v1/files`
|
||||
with `purpose=vision` (or `user_data`) keeps it, for as long as job results
|
||||
are kept, as a file of yours that `generate_mesh`, `review_images` and a
|
||||
workflow's file inputs take as `file-…`. A call is at most 64 MB.
|
||||
|
||||
A model that is not loaded is waited for: a picture or a look at one whose
|
||||
model is starting, or waiting for another to give up the GPU, waits as a
|
||||
mesh does, saying so, rather than failing with "model loading".
|
||||
|
||||
A mesh takes from half a minute to a few minutes. A client that takes the
|
||||
answer as a stream (`Accept: text/event-stream`, as Claude Code does) is
|
||||
answered that way: `notifications/progress` for the call's
|
||||
`progressToken` while it runs — queued, waiting for the GPU, the node and
|
||||
its percent — then the result, and the call waits up to ten minutes. One
|
||||
answered in one piece waits 50 seconds; a job not done by then is answered
|
||||
with its `job_id`, to ask after with `job_status`. `wait_seconds` (up to
|
||||
900) sets either.
|
||||
|
||||
```
|
||||
claude mcp add --transport http llmgw https://llm.example.com/mcp \
|
||||
--header "Authorization: Bearer $LLMGW_API_KEY"
|
||||
```
|
||||
|
||||
### Licences you leave out
|
||||
|
||||
A model's licence is in `/v1/models` (`license`: its `id`, `name`, whether
|
||||
|
||||
@@ -1827,6 +1827,23 @@ same event about the same thing is sent at most once an hour, so a budget that
|
||||
refuses a thousand requests is one message. `llmgw ctl notify test` sends a
|
||||
test event to each webhook and says how each answered.
|
||||
|
||||
Setup asks for them: **Notification webhooks** on its first page, or
|
||||
`--webhook` to `llmgw server init`, as often as there are webhooks:
|
||||
|
||||
```
|
||||
sudo llmgw server init --admin ops@example.com \
|
||||
--webhook https://hooks.slack.com/services/… \
|
||||
--webhook json:https://ops.example.com/llmgw
|
||||
```
|
||||
|
||||
An address of Slack's, or one with `/hooks/` in it (Mattermost, Rocket.Chat)
|
||||
or ending in `/slack`, is sent Slack's `{"text": …}`; any other the event as
|
||||
JSON; `slack:` or `json:` in front says which. They are added to the
|
||||
cluster's settings beside any already there — run again, nothing is added
|
||||
twice — and once the control plane runs on them, each is sent a test, whose
|
||||
answer setup prints. Events to choose and a signing secret are set in the
|
||||
settings afterwards (`llmgw ctl config edit`).
|
||||
|
||||
### Tracing
|
||||
|
||||
Metrics say that a model took four minutes to start; a trace says where they
|
||||
|
||||
@@ -205,6 +205,8 @@ rather than repeated.
|
||||
| `no_passwords` | `bool` | [OIDC](#oidc) |
|
||||
| `mirror` | `string` | [Offline](#offline) |
|
||||
| `checksums` | `string` | [Offline](#offline) |
|
||||
| `evict_after_idle` | `time.Duration` | [OnDemandSettings](#ondemandsettings) |
|
||||
| `min_lifetime` | `time.Duration` | [OnDemandSettings](#ondemandsettings) |
|
||||
| `nvidia_smi` | `string` | [Options](#options) |
|
||||
| `ib_root` | `string` | [Options](#options) |
|
||||
| `prefer_nvml` | `bool` | [Options](#options) |
|
||||
@@ -346,6 +348,7 @@ rather than repeated.
|
||||
| `serving` | `ServingSettings` | [Server](#server) |
|
||||
| `firewall` | `FirewallSettings` | [Server](#server) |
|
||||
| `presets` | `PresetSettings` | [Server](#server) |
|
||||
| `on_demand` | `OnDemandSettings` | [Server](#server) |
|
||||
| `agents` | `AgentSettings` | [Server](#server) |
|
||||
| `firewall_networks` | `[]string` | [Server](#server) |
|
||||
| `backup_recipients` | `[]string` | [Server](#server) |
|
||||
@@ -1912,6 +1915,25 @@ kept by llmgw bundle import and llmgw bundle trust; every engine file
|
||||
a node fetches from the mirror is checked against it. Read again
|
||||
whenever it changes. Empty is /etc/llmgw/offline.sha256.
|
||||
|
||||
## OnDemandSettings
|
||||
|
||||
### `evict_after_idle`
|
||||
|
||||
`time.Duration`
|
||||
|
||||
EvictAfterIdle is how long an on-demand replica must go unasked-for
|
||||
before a model that wants its card may have it. Zero keeps 2m.
|
||||
|
||||
### `min_lifetime`
|
||||
|
||||
`time.Duration`
|
||||
|
||||
MinLifetime is how long a replica that has just come up is safe from
|
||||
that, so two models do not take turns paying for loads. Zero keeps
|
||||
5m. On one card shared by a picture model and a 3D model, 1m each
|
||||
brings the wait between a picture and its mesh to about a minute.
|
||||
Both are in force on the leader within seconds.
|
||||
|
||||
## Options
|
||||
|
||||
### `nvidia_smi`
|
||||
@@ -2984,6 +3006,15 @@ Presets is how the models that come with llmgw reach the catalog.
|
||||
|
||||
Takes a [PresetSettings](#presetsettings) block.
|
||||
|
||||
### `on_demand`
|
||||
|
||||
`OnDemandSettings`
|
||||
|
||||
OnDemand is how models that load when asked for take turns on the
|
||||
cards.
|
||||
|
||||
Takes a [OnDemandSettings](#ondemandsettings) block.
|
||||
|
||||
### `agents`
|
||||
|
||||
`AgentSettings`
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -1131,6 +1132,31 @@ func (a *Agent) startShard(ctx context.Context, m *llmgwv1.StartShard, out chan<
|
||||
stop()
|
||||
spec.Profile.ModelPath = path
|
||||
}
|
||||
// A shared ComfyUI replica's members' weights, each fetched like the
|
||||
// model's, and handed to the gate where they landed.
|
||||
if raw := spec.Profile.Env[engines.SharedModelsEnv]; raw != "" {
|
||||
var ms []map[string]string
|
||||
if err := json.Unmarshal([]byte(raw), &ms); err != nil {
|
||||
report(supervisor.Failed, "the shared replica's models are not readable: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
for i, m := range ms {
|
||||
stop := a.watchFetch(m["source"], func(p weights.Progress) {
|
||||
send(&llmgwv1.ShardStatus{State: agentsvc.StateToWire(supervisor.Starting),
|
||||
Fetch: &llmgwv1.FetchProgress{Source: p.Source, BytesDone: p.BytesDone, BytesTotal: p.BytesTotal}})
|
||||
})
|
||||
path, err := a.weights().Ensure(ctx, m["source"])
|
||||
stop()
|
||||
if err != nil {
|
||||
report(supervisor.Failed, fmt.Sprintf("model %s: %v", m["name"], err), nil)
|
||||
return
|
||||
}
|
||||
ms[i]["path"] = path
|
||||
}
|
||||
b, _ := json.Marshal(ms)
|
||||
spec.Profile.Env = maps.Clone(spec.Profile.Env)
|
||||
spec.Profile.Env[engines.SharedModelsEnv] = string(b)
|
||||
}
|
||||
// Adapters are fetched like the model, and before the engine starts: it
|
||||
// loads them at start, and one missing is a replica that answers for the
|
||||
// base model under the adapter's name.
|
||||
|
||||
@@ -129,7 +129,7 @@ func (c *ComfyUI) Commands(s ShardSpec) ([]Command, error) {
|
||||
// so taken before baseEnv drops them. Without the repository the gate
|
||||
// links no Hugging Face cache, and sf3d's node went to the network for
|
||||
// its gated repository and was refused with a 401.
|
||||
for _, k := range []string{comfyNodesEnv, comfyHFRepoEnv} {
|
||||
for _, k := range []string{comfyNodesEnv, comfyHFRepoEnv, SharedModelsEnv} {
|
||||
if v := p.Env[k]; v != "" {
|
||||
env[k] = v
|
||||
}
|
||||
@@ -144,3 +144,8 @@ const comfyNodesEnv = "LLMGW_COMFYUI_NODES"
|
||||
// comfyHFRepoEnv is the repository a custom node loads the weights by name
|
||||
// from; catalog.HFRepoEnv and comfy.HFRepoEnv by the same name.
|
||||
const comfyHFRepoEnv = "LLMGW_COMFYUI_HF_REPO"
|
||||
|
||||
// SharedModelsEnv is a shared replica's members — name, source, and the
|
||||
// path the agent fetched it to — as JSON; catalog.SharedModelsEnv and
|
||||
// comfy.SharedModelsEnv by the same name.
|
||||
const SharedModelsEnv = "LLMGW_COMFYUI_MODELS"
|
||||
@@ -245,6 +245,8 @@ var liveSettings = []string{
|
||||
"agents",
|
||||
// Read by the leader's presets pass each time it runs.
|
||||
"presets",
|
||||
// Handed to the reconciler as they change.
|
||||
"on_demand",
|
||||
// Read by server init, node add and backups, not by the server.
|
||||
"backup_recipients", "backup_copy_to",
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -778,6 +779,9 @@ func newServerCmd() *cobra.Command {
|
||||
fwPub.Kick()
|
||||
}
|
||||
}
|
||||
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "on_demand") }) && admin != nil {
|
||||
admin.Reconciler().SetEvictionTimes(next.OnDemand.EvictAfterIdle, next.OnDemand.MinLifetime)
|
||||
}
|
||||
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "agents") }) && agentSvc != nil {
|
||||
// A moment apart per node, so off this goroutine.
|
||||
go agentSvc.BroadcastAgentSettings()
|
||||
|
||||
@@ -73,6 +73,9 @@ type serverInit struct {
|
||||
// patroni are user@host of the machines to keep the store on under
|
||||
// Patroni, replicated; this machine is the third etcd vote for two.
|
||||
patroni []string
|
||||
// webhooks are where the cluster's alerts go: put into its settings
|
||||
// once it runs, and each sent a test.
|
||||
webhooks []string
|
||||
// patroniNodes are the same machines already connected to, which the
|
||||
// setup screen does with the password it was given; when set, patroni
|
||||
// only names them.
|
||||
@@ -182,6 +185,9 @@ func newServerInitCmd() *cobra.Command {
|
||||
"leave the firewall alone, instead of keeping llmgw's internal ports to the cluster's networks")
|
||||
f.BoolVar(&in.postgres, "postgres", false,
|
||||
"keep the store in PostgreSQL on this machine, installing it and moving an existing SQLite store into it")
|
||||
f.StringArrayVar(&in.webhooks, "webhook", nil,
|
||||
"where alerts go — a model parked, a node lost, a budget spent: an http(s) address, Slack's, Mattermost's "+
|
||||
"and Rocket.Chat's sent Slack's text, any other the event as JSON (slack: or json: in front says which); repeatable")
|
||||
f.StringArrayVar(&in.patroni, "patroni", nil,
|
||||
"user@host of a machine to keep the store on, replicated under Patroni; give two or more. "+
|
||||
"Installs PostgreSQL, Patroni and etcd there over ssh, and moves the existing store in")
|
||||
@@ -249,6 +255,9 @@ func (in *serverInit) run(ctx context.Context) error {
|
||||
fmt.Fprintf(in.out, " !! firewall: %v; llmgw's ports answer every network this machine is on\n", err)
|
||||
}
|
||||
}
|
||||
if err := in.ensureWebhooks(ctx); err != nil {
|
||||
fmt.Fprintf(in.out, " !! webhooks: %v\n", err)
|
||||
}
|
||||
if in.judgeWanted() && in.configJudgesWith(guardModelName) {
|
||||
// Not fatal: without its judge the baseline policy still checks for
|
||||
// secrets and injection, and a judged request is served flagged.
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||
)
|
||||
|
||||
// Where the cluster says what went wrong: a model parked, a node lost, a
|
||||
// budget spent. notify.webhooks existed, and setup never asked for one, so a
|
||||
// site learnt of a lost node from its users.
|
||||
|
||||
// parseWebhook reads a webhook as setup takes it: an address, with "slack:"
|
||||
// or "json:" in front to say what it is sent, else what its address says.
|
||||
func parseWebhook(s string) (notify.Webhook, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
format := ""
|
||||
for _, f := range []string{"slack", "json"} {
|
||||
if rest, ok := strings.CutPrefix(s, f+":"); ok && !strings.HasPrefix(rest, "//") {
|
||||
format, s = f, rest
|
||||
}
|
||||
}
|
||||
u, err := url.Parse(s)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" {
|
||||
return notify.Webhook{}, fmt.Errorf("webhook %q: an http(s) address, with slack: or json: in front to say its format", s)
|
||||
}
|
||||
if format == "" {
|
||||
format = webhookFormat(u)
|
||||
}
|
||||
return notify.Webhook{URL: s, Format: format}, nil
|
||||
}
|
||||
|
||||
// webhookFormat is what an address takes: Slack's own, and the incoming
|
||||
// webhooks that read Slack's {"text": …} — Mattermost's and Rocket.Chat's
|
||||
// /hooks/, Discord's …/slack — are sent that; anything else the event as
|
||||
// JSON.
|
||||
func webhookFormat(u *url.URL) string {
|
||||
switch {
|
||||
case u.Host == "hooks.slack.com",
|
||||
strings.Contains(u.Path, "/hooks/"),
|
||||
strings.HasSuffix(u.Path, "/slack"):
|
||||
return "slack"
|
||||
}
|
||||
return "json"
|
||||
}
|
||||
|
||||
// parseWebhooks reads a list of them, by commas or as given.
|
||||
func parseWebhooks(list []string) ([]notify.Webhook, error) {
|
||||
var out []notify.Webhook
|
||||
for _, item := range list {
|
||||
for _, s := range strings.Split(item, ",") {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
continue
|
||||
}
|
||||
w, err := parseWebhook(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// webhookEntry is one of notify.webhooks as the settings document has it.
|
||||
type webhookEntry struct {
|
||||
URL string `yaml:"url"`
|
||||
Format string `yaml:"format,omitempty"`
|
||||
Events []string `yaml:"events,omitempty,flow"`
|
||||
SecretFile string `yaml:"secret_file,omitempty"`
|
||||
}
|
||||
|
||||
// withWebhooks adds these webhooks to a settings document's notify.webhooks,
|
||||
// and says whether that changed it: one already there, by address, is left
|
||||
// as it is, with whatever events and secret it was given.
|
||||
func withWebhooks(doc *settings.Doc, add []notify.Webhook) (bool, error) {
|
||||
path := []string{"notify", "webhooks"}
|
||||
var list []webhookEntry
|
||||
if n := doc.Node(path); n != nil {
|
||||
if err := n.Decode(&list); err != nil {
|
||||
return false, fmt.Errorf("notify.webhooks in the cluster's settings: %w", err)
|
||||
}
|
||||
}
|
||||
changed := false
|
||||
for _, w := range add {
|
||||
if !slices.ContainsFunc(list, func(e webhookEntry) bool { return e.URL == w.URL }) {
|
||||
list = append(list, webhookEntry{URL: w.URL, Format: w.Format})
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if !changed {
|
||||
return false, nil
|
||||
}
|
||||
b, err := yaml.Marshal(list)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, doc.SetYAML(path, string(b))
|
||||
}
|
||||
|
||||
// ensureWebhooks puts the webhooks given into the cluster's settings and
|
||||
// sends each a test, once the control plane runs on them. Said, not fatal: a
|
||||
// cluster without its alerts still serves.
|
||||
func (in *serverInit) ensureWebhooks(ctx context.Context) error {
|
||||
if len(in.webhooks) == 0 {
|
||||
return nil
|
||||
}
|
||||
hooks, err := parseWebhooks(in.webhooks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
c := in.adminAPI()
|
||||
var cur adminapi.SettingsView
|
||||
if err := c.get(ctx, "/api/v1/settings", &cur); err != nil {
|
||||
return err
|
||||
}
|
||||
doc, err := settings.FromBytes("settings", []byte(cur.Body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
changed, err := withWebhooks(doc, hooks)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
b, err := doc.Bytes()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req, _ := json.Marshal(adminapi.SettingsRequest{Base: cur.Version, Body: string(b), Note: "notification webhooks, from setup"})
|
||||
if err := c.put(ctx, "/api/v1/settings", req, &cur); err != nil {
|
||||
return err
|
||||
}
|
||||
in.done("notification webhooks in the cluster's settings (version %d)", cur.Version)
|
||||
} else {
|
||||
in.kept("notification webhooks (already in the cluster's settings)")
|
||||
}
|
||||
|
||||
// The control planes start again on the change; the test is sent by
|
||||
// one that knows every webhook asked for.
|
||||
var res []notify.Result
|
||||
err = waitFor(ctx, 2*time.Minute, "the control plane to run on its webhooks", func() (bool, error) {
|
||||
res = nil
|
||||
if c.post(ctx, "/api/v1/notify/test", nil, &res) != nil {
|
||||
return false, nil
|
||||
}
|
||||
for _, w := range hooks {
|
||||
// Answered by host: the path of a webhook is its secret.
|
||||
if !slices.ContainsFunc(res, func(r notify.Result) bool { return r.URL == notify.Redact(w.URL) }) {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var failed []string
|
||||
for _, r := range res {
|
||||
if !slices.ContainsFunc(hooks, func(w notify.Webhook) bool { return notify.Redact(w.URL) == r.URL }) {
|
||||
continue
|
||||
}
|
||||
if r.Err != "" {
|
||||
failed = append(failed, r.URL+": "+r.Err)
|
||||
continue
|
||||
}
|
||||
in.done("a test notification reached %s", r.URL)
|
||||
}
|
||||
if len(failed) > 0 {
|
||||
return errors.New("a test notification did not arrive: " + strings.Join(failed, "; ") +
|
||||
" (kept in the settings: 'llmgw ctl notify test' tries again)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/settings"
|
||||
)
|
||||
|
||||
func TestAWebhooksFormatIsWhatItsAddressTakes(t *testing.T) {
|
||||
for in, want := range map[string]string{
|
||||
"https://hooks.slack.com/services/T1/B2/abc": "slack",
|
||||
"https://chat.example.com/hooks/xyz": "slack", // Mattermost, Rocket.Chat
|
||||
"https://discord.com/api/webhooks/1/abc/slack": "slack",
|
||||
"https://alerts.example.com/llmgw": "json",
|
||||
"json:https://hooks.slack.com/services/T1/B2/abc": "json",
|
||||
"slack:https://alerts.example.com/llmgw": "slack",
|
||||
" https://events.pagerduty.com/integration/x/enqueue ": "json",
|
||||
} {
|
||||
w, err := parseWebhook(in)
|
||||
if err != nil || w.Format != want || strings.HasPrefix(w.URL, "slack:") || strings.HasPrefix(w.URL, "json:") {
|
||||
t.Errorf("%q: %+v %v, want %s", in, w, err, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"hooks.slack.com/x", "ftp://example.com/x", "slack:", "https://"} {
|
||||
if _, err := parseWebhook(bad); err == nil {
|
||||
t.Errorf("%q was taken", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhooksAreAddedToTheSettingsBesideTheOnesThere(t *testing.T) {
|
||||
doc, err := settings.FromBytes("settings", []byte("router:\n retries: 2\nnotify:\n webhooks:\n"+
|
||||
" - url: https://alerts.example.com/a\n events: [node_lost]\n secret_file: /etc/llmgw/secrets/hook\n"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hooks, _ := parseWebhooks([]string{"https://alerts.example.com/a, https://hooks.slack.com/services/T/B/c"})
|
||||
changed, err := withWebhooks(doc, hooks)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("changed %v: %v", changed, err)
|
||||
}
|
||||
b, _ := doc.Bytes()
|
||||
got := string(b)
|
||||
for _, want := range []string{"retries: 2", "url: https://alerts.example.com/a", "events: [node_lost]",
|
||||
"secret_file: /etc/llmgw/secrets/hook", "url: https://hooks.slack.com/services/T/B/c", "format: slack"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("%q not in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Count(got, "alerts.example.com/a") != 1 {
|
||||
t.Errorf("a webhook already there was added again:\n%s", got)
|
||||
}
|
||||
if changed, _ := withWebhooks(doc, hooks); changed {
|
||||
t.Error("adding the same webhooks again changed the settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetupPutsItsWebhooksInTheSettingsAndTestsThemOnceInForce(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
body, tests := "router:\n retries: 2\n", 0
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch {
|
||||
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/settings":
|
||||
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 1, Body: body})
|
||||
case r.Method == http.MethodPut && r.URL.Path == "/api/v1/settings":
|
||||
var req adminapi.SettingsRequest
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
body = req.Body
|
||||
_ = json.NewEncoder(w).Encode(adminapi.SettingsView{Version: 2, Body: body})
|
||||
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/notify/test":
|
||||
tests++
|
||||
// The first answer comes from the control plane before it
|
||||
// started again on the change: it knows no webhooks.
|
||||
res := []notify.Result{}
|
||||
if tests > 1 {
|
||||
res = []notify.Result{{URL: notify.Redact("https://hooks.slack.com/services/T/B/c")},
|
||||
{URL: notify.Redact("https://ops.example.com/in"), Err: "502 Bad Gateway"}}
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(res)
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "server.yaml"),
|
||||
[]byte("admin_listen: "+strings.TrimPrefix(srv.URL, "http://")+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
in := &serverInit{etcDir: dir, out: &out,
|
||||
webhooks: []string{"https://hooks.slack.com/services/T/B/c", "https://ops.example.com/in"}}
|
||||
err := in.ensureWebhooks(t.Context())
|
||||
if err == nil || !strings.Contains(err.Error(), "502") || !strings.Contains(err.Error(), "ctl notify test") {
|
||||
t.Errorf("a webhook that did not answer: %v", err)
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if !strings.Contains(body, "format: slack") || !strings.Contains(body, "url: https://ops.example.com/in") ||
|
||||
!strings.Contains(body, "retries: 2") || tests < 2 {
|
||||
t.Errorf("settings %q after %d tests", body, tests)
|
||||
}
|
||||
if strings.Contains(out.String(), "/services/T/B/c") {
|
||||
t.Errorf("a webhook's secret path was printed:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "reached https://hooks.slack.com/…") {
|
||||
t.Errorf("the one that answered is not said:\n%s", out.String())
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,7 @@ func (w *lineWriter) Write(p []byte) (int, error) {
|
||||
const (
|
||||
fAdmin = iota
|
||||
fHosts
|
||||
fWebhooks
|
||||
fPostgres
|
||||
fMirror
|
||||
fNode
|
||||
@@ -70,6 +71,7 @@ type initModel struct {
|
||||
focus int
|
||||
admin textField
|
||||
hosts textField
|
||||
webhooks textField
|
||||
bundle textField
|
||||
bundleSHA textField
|
||||
toggles [fCount]bool
|
||||
@@ -103,6 +105,11 @@ func newInitModel(in *serverInit, found []string, askForm bool) initModel {
|
||||
hosts.SetValue(strings.Join(in.hosts, ", "))
|
||||
hosts.CharLimit = 400
|
||||
hosts.Width = 40
|
||||
webhooks := newTextField()
|
||||
webhooks.Placeholder = "https://hooks.slack.com/services/…"
|
||||
webhooks.SetValue(strings.Join(in.webhooks, ", "))
|
||||
webhooks.CharLimit = 800
|
||||
webhooks.Width = 40
|
||||
bundle := newTextField()
|
||||
bundle.Placeholder = "/media/usb/llmgw-bundle.tar"
|
||||
bundle.SetValue(in.bundle)
|
||||
@@ -114,7 +121,7 @@ func newInitModel(in *serverInit, found []string, askForm bool) initModel {
|
||||
bundleSHA.CharLimit = 64
|
||||
bundleSHA.Width = 64
|
||||
sp := newSpinner()
|
||||
m := initModel{in: in, found: found, form: askForm, admin: admin, hosts: hosts,
|
||||
m := initModel{in: in, found: found, form: askForm, admin: admin, hosts: hosts, webhooks: webhooks,
|
||||
bundle: bundle, bundleSHA: bundleSHA, spin: sp, width: 100}
|
||||
m.toggles[fPostgres] = in.postgres
|
||||
m.toggles[fMirror] = !in.noMirror
|
||||
@@ -235,6 +242,17 @@ func (m initModel) formKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.moveFocus(0)
|
||||
return m, nil
|
||||
}
|
||||
hooks := strings.TrimSpace(m.webhooks.Value())
|
||||
if _, err := parseWebhooks([]string{hooks}); err != nil {
|
||||
m.formError = err.Error()
|
||||
m.focus = fWebhooks
|
||||
m.moveFocus(0)
|
||||
return m, nil
|
||||
}
|
||||
m.in.webhooks = nil
|
||||
if hooks != "" {
|
||||
m.in.webhooks = []string{hooks}
|
||||
}
|
||||
m.in.bundle, m.in.bundleSHA = bundle, sum
|
||||
m.in.admin = admin
|
||||
m.in.hosts = nil
|
||||
@@ -260,6 +278,10 @@ func (m initModel) formKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
m.hosts, cmd = m.hosts.Update(k)
|
||||
return m, cmd
|
||||
case fWebhooks:
|
||||
var cmd tea.Cmd
|
||||
m.webhooks, cmd = m.webhooks.Update(k)
|
||||
return m, cmd
|
||||
case fBundle:
|
||||
var cmd tea.Cmd
|
||||
m.bundle, cmd = m.bundle.Update(k)
|
||||
@@ -284,6 +306,7 @@ func (m *initModel) moveFocus(d int) {
|
||||
}
|
||||
m.admin.Blur()
|
||||
m.hosts.Blur()
|
||||
m.webhooks.Blur()
|
||||
m.bundle.Blur()
|
||||
m.bundleSHA.Blur()
|
||||
switch m.focus {
|
||||
@@ -291,6 +314,8 @@ func (m *initModel) moveFocus(d int) {
|
||||
m.admin.Focus()
|
||||
case fHosts:
|
||||
m.hosts.Focus()
|
||||
case fWebhooks:
|
||||
m.webhooks.Focus()
|
||||
case fBundle:
|
||||
m.bundle.Focus()
|
||||
case fBundleSHA:
|
||||
@@ -409,6 +434,8 @@ func (m initModel) formView() string {
|
||||
found = strings.Join(m.found, ", ")
|
||||
}
|
||||
b.WriteString(" " + sDim.Render("Already in the certificate: "+found+". Add a DNS name or NAT address here.") + "\n\n")
|
||||
fmt.Fprintf(&b, "%s\n %s\n", label(fWebhooks, "Notification webhooks"), m.webhooks.View())
|
||||
b.WriteString(" " + sDim.Render("Where a parked model, a lost node or a spent budget is said: Slack, Mattermost, or any URL taking JSON. Comma-separated; a test is sent.") + "\n\n")
|
||||
b.WriteString(check(fPostgres, "PostgreSQL", "rather than SQLite; installed here, for several control planes or a larger store"))
|
||||
if m.in.mirrorURL != "" {
|
||||
b.WriteString(" Model mirror " + sDim.Render("at "+m.in.mirrorURL+", from --mirror") + "\n")
|
||||
|
||||
@@ -20,6 +20,8 @@ func TestTheInitFormBecomesInitsSettings(t *testing.T) {
|
||||
m = typeIn(m, "ops@example.com")
|
||||
m = send(m, tea.KeyMsg{Type: tea.KeyTab})
|
||||
m = typeIn(m, "llm.example.com, 203.0.113.10")
|
||||
m = send(m, tea.KeyMsg{Type: tea.KeyTab})
|
||||
m = typeIn(m, "https://hooks.slack.com/services/T1/B2/x, json:https://alerts.example.com/in")
|
||||
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" ")}) // PostgreSQL on
|
||||
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab},
|
||||
tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(" ")}) // starter model off
|
||||
@@ -33,11 +35,30 @@ func TestTheInitFormBecomesInitsSettings(t *testing.T) {
|
||||
if !in.postgres || in.noMirror || in.noNode || !in.noModel || !in.noGuardModel {
|
||||
t.Errorf("toggles: postgres %v mirror %v node %v model %v", in.postgres, !in.noMirror, !in.noNode, !in.noModel)
|
||||
}
|
||||
if hooks, err := parseWebhooks(in.webhooks); err != nil || len(hooks) != 2 || hooks[0].Format != "slack" || hooks[1].Format != "json" {
|
||||
t.Errorf("webhooks %q: %+v %v", in.webhooks, hooks, err)
|
||||
}
|
||||
if m.(initModel).form {
|
||||
t.Error("the form stayed up after Set up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWebhookThatIsNoAddressKeepsTheFormUp(t *testing.T) {
|
||||
in := &serverInit{}
|
||||
var m tea.Model = newInitModel(in, nil, true)
|
||||
for range fWebhooks {
|
||||
m = send(m, tea.KeyMsg{Type: tea.KeyTab})
|
||||
}
|
||||
m = typeIn(m, "hooks.slack.com/services/x")
|
||||
for range fStart - fWebhooks {
|
||||
m = send(m, tea.KeyMsg{Type: tea.KeyTab})
|
||||
}
|
||||
m = send(m, tea.KeyMsg{Type: tea.KeyEnter})
|
||||
if !m.(initModel).form || m.(initModel).focus != fWebhooks || !strings.Contains(m.View(), "http(s) address") {
|
||||
t.Errorf("an address without its scheme was taken:\n%s", m.View())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnOfflineBundleIsGivenInTheFormWithItsDigest(t *testing.T) {
|
||||
in := &serverInit{}
|
||||
var m tea.Model = newInitModel(in, nil, true)
|
||||
@@ -117,9 +138,10 @@ func TestWithAMirrorElsewhereTheFormDoesNotOfferOne(t *testing.T) {
|
||||
if v := m.View(); !strings.Contains(v, "at http://10.0.0.150:8082, from --mirror") {
|
||||
t.Errorf("the mirror in use is not shown:\n%s", v)
|
||||
}
|
||||
// Tab from the addresses goes past the mirror line to PostgreSQL, then
|
||||
// the node.
|
||||
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab})
|
||||
// Tab from the addresses goes to the webhooks, past the mirror line to
|
||||
// PostgreSQL, then the node.
|
||||
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab},
|
||||
tea.KeyMsg{Type: tea.KeyTab})
|
||||
if f := m.(initModel).focus; f != fNode {
|
||||
t.Errorf("focus %d, want the node toggle %d", f, fNode)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
|
||||
joinpkg "gitea.mynodes.xyz/ilker/llmgw/internal/join"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/notify"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/onboard"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
|
||||
@@ -150,8 +151,8 @@ type setupModel struct {
|
||||
ips []string
|
||||
choice int // 0 one machine, 1 high availability
|
||||
|
||||
admin, names textField
|
||||
afocus int // admin, names, next
|
||||
admin, names, webhooks textField
|
||||
afocus int // admin, names, webhooks, next
|
||||
|
||||
db [2]dbMachine
|
||||
mfocus int // over both machines' fields, then the check button
|
||||
@@ -184,8 +185,10 @@ func newSetupModel(host string, ips []string) setupModel {
|
||||
admin.Placeholder, admin.CharLimit, admin.Width = "you@example.com", 200, 40
|
||||
names := newTextField()
|
||||
names.Placeholder, names.CharLimit, names.Width = "llm.example.com, 203.0.113.10", 400, 40
|
||||
webhooks := newTextField()
|
||||
webhooks.Placeholder, webhooks.CharLimit, webhooks.Width = "https://hooks.slack.com/services/…", 800, 40
|
||||
return setupModel{
|
||||
ref: &progRef{}, host: host, ips: ips, admin: admin, names: names,
|
||||
ref: &progRef{}, host: host, ips: ips, admin: admin, names: names, webhooks: webhooks,
|
||||
db: [2]dbMachine{newDBMachine(1), newDBMachine(2)}, spin: newSpinner(), width: 100, height: 30,
|
||||
log: &[]string{}, logMu: &sync.Mutex{},
|
||||
}
|
||||
@@ -308,14 +311,17 @@ func (m setupModel) key(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m setupModel) adminKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
move := func(d int) {
|
||||
m.afocus = (m.afocus + d + 3) % 3
|
||||
m.afocus = (m.afocus + d + 4) % 4
|
||||
m.admin.Blur()
|
||||
m.names.Blur()
|
||||
m.webhooks.Blur()
|
||||
switch m.afocus {
|
||||
case 0:
|
||||
m.admin.Focus()
|
||||
case 1:
|
||||
m.names.Focus()
|
||||
case 2:
|
||||
m.webhooks.Focus()
|
||||
}
|
||||
}
|
||||
switch k.Type {
|
||||
@@ -326,7 +332,7 @@ func (m setupModel) adminKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
move(-1)
|
||||
return m, nil
|
||||
case tea.KeyEnter:
|
||||
if m.afocus < 2 {
|
||||
if m.afocus < 3 {
|
||||
move(1)
|
||||
return m, nil
|
||||
}
|
||||
@@ -336,6 +342,12 @@ func (m setupModel) adminKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
move(0)
|
||||
return m, nil
|
||||
}
|
||||
if _, err := parseWebhooks(m.webhookList()); err != nil {
|
||||
m.formErr = err.Error()
|
||||
m.afocus = 2
|
||||
move(0)
|
||||
return m, nil
|
||||
}
|
||||
m.formErr = ""
|
||||
m.page, m.mfocus = pMachines, 0
|
||||
m.db[0].addr.Focus()
|
||||
@@ -347,10 +359,23 @@ func (m setupModel) adminKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
m.admin, cmd = m.admin.Update(k)
|
||||
case 1:
|
||||
m.names, cmd = m.names.Update(k)
|
||||
case 2:
|
||||
m.webhooks, cmd = m.webhooks.Update(k)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// webhookList is the webhooks asked for, one an item.
|
||||
func (m setupModel) webhookList() []string {
|
||||
var out []string
|
||||
for _, s := range strings.Split(m.webhooks.Value(), ",") {
|
||||
if s = strings.TrimSpace(s); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (m *setupModel) focusMachine() {
|
||||
for i := range m.db {
|
||||
for f := 0; f < mfCount; f++ {
|
||||
@@ -589,6 +614,9 @@ func (m setupModel) command() string {
|
||||
for _, t := range m.targets() {
|
||||
parts = append(parts, "--patroni "+t)
|
||||
}
|
||||
for _, w := range m.webhookList() {
|
||||
parts = append(parts, "--webhook "+w)
|
||||
}
|
||||
parts = append(parts, "--no-node", "--no-model")
|
||||
return strings.Join(parts, " \\\n ")
|
||||
}
|
||||
@@ -600,6 +628,7 @@ func (m *setupModel) startRun() tea.Cmd {
|
||||
in.admin = strings.TrimSpace(m.admin.Value())
|
||||
in.hosts = m.namesList()
|
||||
in.patroni = m.targets()
|
||||
in.webhooks = m.webhookList()
|
||||
// The control plane of a replicated setup is not where models run;
|
||||
// nodes are added once it is up.
|
||||
in.noNode, in.noModel = true, true
|
||||
@@ -759,7 +788,9 @@ func (m setupModel) adminView() string {
|
||||
sDim.Render("A password is generated and shown once, at the end."))
|
||||
fmt.Fprintf(&b, "%s\n %s\n %s\n\n", label(1, "Other names nodes will reach this machine by"), m.names.View(),
|
||||
sDim.Render("Known: "+strings.Join(append([]string{m.host}, m.ips...), ", ")+". Add a DNS name or a floating address."))
|
||||
b.WriteString(button(m.afocus == 2, "Next") + "\n")
|
||||
fmt.Fprintf(&b, "%s\n %s\n %s\n\n", label(2, "Notification webhooks"), m.webhooks.View(),
|
||||
sDim.Render("Where a parked model, a lost node or a spent budget is said: Slack, Mattermost, or any URL taking JSON. Optional; a test is sent."))
|
||||
b.WriteString(button(m.afocus == 3, "Next") + "\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
@@ -880,6 +911,13 @@ func (m setupModel) planView() string {
|
||||
fmt.Fprintf(&b, " %s %s\n %s\n", lipgloss.NewStyle().Bold(true).Render(m.host), sDim.Render(m.thisAddr()),
|
||||
"control plane and gateway, model mirror, etcd (the third vote)")
|
||||
fmt.Fprintf(&b, " administrator %s\n", strings.TrimSpace(m.admin.Value()))
|
||||
if hooks, _ := parseWebhooks(m.webhookList()); len(hooks) > 0 {
|
||||
var where []string
|
||||
for _, h := range hooks {
|
||||
where = append(where, notify.Redact(h.URL)+" ("+h.Format+")")
|
||||
}
|
||||
fmt.Fprintf(&b, " alerts to %s\n", strings.Join(where, ", "))
|
||||
}
|
||||
granted := false
|
||||
for _, d := range m.db {
|
||||
addr := strings.TrimSpace(d.addr.Value())
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestTheSetupAsksForWhatHighAvailabilityNeedsAndRefusesWhatWillNotDo(t *test
|
||||
t.Fatalf("high availability did not lead to the administrator: page %d", s.page)
|
||||
}
|
||||
// No email: refused, with a reason.
|
||||
m = press(m, "tab", "tab", "enter")
|
||||
m = press(m, "tab", "tab", "tab", "enter")
|
||||
if s := m.(setupModel); s.page != pAdmin || !strings.Contains(s.formErr, "email") {
|
||||
t.Fatalf("went on without an administrator: %+v", s.formErr)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestTheSetupAsksForWhatHighAvailabilityNeedsAndRefusesWhatWillNotDo(t *test
|
||||
if !strings.Contains(m.View(), "Not yet") {
|
||||
t.Error("the refusal was not shown as a dialog")
|
||||
}
|
||||
m = press(m, "x", "a@example.com", "tab", "tab", "enter")
|
||||
m = press(m, "x", "a@example.com", "tab", "tab", "tab", "enter")
|
||||
s := m.(setupModel)
|
||||
if s.page != pMachines {
|
||||
t.Fatalf("page %d: %s", s.page, s.formErr)
|
||||
|
||||
@@ -107,7 +107,7 @@ var Routes = []Route{
|
||||
{Method: "GET", Path: "/v1/memory/{key}", Summary: "A note", Response: MemoryNote{}},
|
||||
{Method: "GET", Path: "/v1/memory", Summary: "The project's notes", Response: MemoryList{}},
|
||||
{Method: "DELETE", Path: "/v1/memory/{key}", Summary: "Forget a note"},
|
||||
{Method: "POST", Path: "/mcp", Summary: "The gateway's MCP server: the memory notes as tools",
|
||||
{Method: "POST", Path: "/mcp", Summary: "The gateway's MCP server: the memory notes, and pictures, 3D models, vision and workflows, as tools",
|
||||
Request: MCPRequest{}, Response: anyJSON},
|
||||
{Method: "GET", Path: "/me", Summary: "The caller: its key, its place in the tree, its limits and what it has used",
|
||||
Response: Me{}},
|
||||
|
||||
+132
-5
@@ -20,6 +20,7 @@ import (
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
@@ -112,7 +113,15 @@ func RunGate(ctx context.Context, cfg GateConfig) error {
|
||||
return err
|
||||
}
|
||||
models := filepath.Join(state, "models")
|
||||
if err := LinkWeights(cfg.Weights, models); err != nil {
|
||||
shared, err := parseShared(os.Getenv(SharedModelsEnv))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
all := []string{cfg.Weights}
|
||||
for _, m := range shared {
|
||||
all = append(all, m.Path)
|
||||
}
|
||||
if err := LinkAllWeights(all, models); err != nil {
|
||||
return err
|
||||
}
|
||||
if repo := os.Getenv(HFRepoEnv); repo != "" {
|
||||
@@ -120,6 +129,20 @@ func RunGate(ctx context.Context, cfg GateConfig) error {
|
||||
return fmt.Errorf("showing the weights as %s: %w", repo, err)
|
||||
}
|
||||
}
|
||||
for _, m := range shared {
|
||||
if m.HFRepo == "" {
|
||||
continue
|
||||
}
|
||||
if err := LinkHFCache(m.Path, m.HFRepo, hfHubDir()); err != nil {
|
||||
return fmt.Errorf("showing %s's weights as %s: %w", m.Name, m.HFRepo, err)
|
||||
}
|
||||
}
|
||||
names := []string{cfg.Model}
|
||||
for _, m := range shared {
|
||||
if !slices.Contains(names, m.Name) {
|
||||
names = append(names, m.Name)
|
||||
}
|
||||
}
|
||||
extra := filepath.Join(state, "extra_model_paths.yaml")
|
||||
if err := os.WriteFile(extra, []byte(extraModelPaths(models)), 0o644); err != nil { //nolint:gosec // not secret
|
||||
return err
|
||||
@@ -177,7 +200,7 @@ func RunGate(ctx context.Context, cfg GateConfig) error {
|
||||
go book.watch(ctx, upstream, clientID, log)
|
||||
srv := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: newGate(upstream, cfg.Key, cfg.Model, book, clientID),
|
||||
Handler: newGate(upstream, cfg.Key, names, book, clientID),
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
}
|
||||
served := make(chan error, 1)
|
||||
@@ -218,12 +241,12 @@ func shutdown(srv *http.Server) {
|
||||
|
||||
// NewGate is the gate's handler, in front of the ComfyUI at upstream.
|
||||
func NewGate(upstream *url.URL, key, model string) http.Handler {
|
||||
return newGate(upstream, key, model, newProgressBook(), "")
|
||||
return newGate(upstream, key, []string{model}, newProgressBook(), "")
|
||||
}
|
||||
|
||||
// newGate is the gate with the book of its workflows' progress, kept under
|
||||
// clientID; an empty one leaves the workflows' own client ids alone.
|
||||
func newGate(upstream *url.URL, key, model string, book *progressBook, clientID string) http.Handler {
|
||||
func newGate(upstream *url.URL, key string, names []string, book *progressBook, clientID string) http.Handler {
|
||||
proxy := httputil.NewSingleHostReverseProxy(upstream)
|
||||
base := proxy.Director
|
||||
proxy.Director = func(r *http.Request) {
|
||||
@@ -269,7 +292,11 @@ func newGate(upstream *url.URL, key, model string, book *progressBook, clientID
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
fmt.Fprintf(w, `{"object":"list","data":[{"id":%q,"object":"model","owned_by":"comfyui"}]}`, model)
|
||||
data := make([]map[string]string, 0, len(names))
|
||||
for _, n := range names {
|
||||
data = append(data, map[string]string{"id": n, "object": "model", "owned_by": "comfyui"})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"object": "list", "data": data})
|
||||
return
|
||||
}
|
||||
if id, ok := strings.CutPrefix(r.URL.Path, "/progress/"); ok && r.Method == http.MethodGet {
|
||||
@@ -306,6 +333,106 @@ func FileName(path string) string {
|
||||
return cachedName.ReplaceAllString(filepath.Base(path), "")
|
||||
}
|
||||
|
||||
// SharedModelsEnv is where the agent hands a shared replica's gate its
|
||||
// members: name, the path their weights were fetched to, and the repository
|
||||
// a node loads them by name from.
|
||||
const SharedModelsEnv = "LLMGW_COMFYUI_MODELS"
|
||||
|
||||
// sharedModel is one member of a shared replica.
|
||||
type sharedModel struct {
|
||||
Name string `json:"name"`
|
||||
Path string `json:"path"`
|
||||
HFRepo string `json:"hf_repo"`
|
||||
}
|
||||
|
||||
func parseShared(raw string) ([]sharedModel, error) {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var ms []sharedModel
|
||||
if err := json.Unmarshal([]byte(raw), &ms); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", SharedModelsEnv, err)
|
||||
}
|
||||
for _, m := range ms {
|
||||
if m.Name == "" || m.Path == "" {
|
||||
return nil, fmt.Errorf("%s: a member with no name or no fetched path", SharedModelsEnv)
|
||||
}
|
||||
}
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// LinkAllWeights shows ComfyUI every model's weights at once, for a replica
|
||||
// that answers for several: links made before are taken away first, then
|
||||
// each file of each is linked by its name. Two weights files of one name
|
||||
// are refused — ComfyUI would load one for both; anything else of one name
|
||||
// (a README, a licence) the first keeps.
|
||||
func LinkAllWeights(weights []string, dir string) error {
|
||||
if len(weights) == 1 {
|
||||
return LinkWeights(weights[0], dir)
|
||||
}
|
||||
if err := LinkWeights(weights[0], dir); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, w := range weights[1:] {
|
||||
files, err := weightFiles(w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, f := range files {
|
||||
link := filepath.Join(dir, FileName(f))
|
||||
if cur, err := os.Readlink(link); err == nil {
|
||||
if cur == f {
|
||||
continue
|
||||
}
|
||||
if isWeightsFile(f) {
|
||||
return fmt.Errorf("two models' weights are both named %s (%s and %s): give one comfyui.checkpoint of another name",
|
||||
FileName(f), cur, f)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := os.Symlink(f, link); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// weightFiles are the files a weights source is: itself, or each of its
|
||||
// directory's.
|
||||
func weightFiles(weights string) ([]string, error) {
|
||||
weights, err := filepath.Abs(weights)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := os.Stat(weights)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the weights: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return []string{weights}, nil
|
||||
}
|
||||
entries, err := os.ReadDir(weights)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
if e.Type().IsRegular() && !strings.HasPrefix(e.Name(), ".") {
|
||||
out = append(out, filepath.Join(weights, e.Name()))
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func isWeightsFile(path string) bool {
|
||||
switch strings.ToLower(filepath.Ext(path)) {
|
||||
case ".safetensors", ".ckpt", ".pt", ".pth", ".bin", ".gguf", ".sft":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// LinkWeights shows ComfyUI the weights: a link in dir to the file, or to
|
||||
// every file of the directory. Links made before are taken away first, so
|
||||
// a replica started again on other weights sees only those.
|
||||
|
||||
@@ -261,3 +261,41 @@ func TestLLMGWsOwnNodesArePutInEveryReplica(t *testing.T) {
|
||||
t.Fatalf("%v %.80s", err, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestASharedReplicaSeesEveryMembersWeights(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
write := func(p string) string {
|
||||
full := filepath.Join(root, p)
|
||||
_ = os.MkdirAll(filepath.Dir(full), 0o755)
|
||||
if err := os.WriteFile(full, []byte("w"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return full
|
||||
}
|
||||
flux := write("flux1-schnell-fp8.safetensors")
|
||||
write("sf3d/model.safetensors")
|
||||
write("sf3d/README.md")
|
||||
write("tripo/model.ckpt")
|
||||
write("tripo/README.md")
|
||||
dir := filepath.Join(root, "models")
|
||||
if err := LinkAllWeights([]string{flux, filepath.Join(root, "sf3d"), filepath.Join(root, "tripo")}, dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{"flux1-schnell-fp8.safetensors", "model.safetensors", "model.ckpt", "README.md"} {
|
||||
if _, err := os.Lstat(filepath.Join(dir, want)); err != nil {
|
||||
t.Errorf("%s not linked: %v", want, err)
|
||||
}
|
||||
}
|
||||
// Two weights of one name: ComfyUI would load one for both.
|
||||
write("other/model.safetensors")
|
||||
err := LinkAllWeights([]string{filepath.Join(root, "sf3d"), filepath.Join(root, "other")}, dir)
|
||||
if err == nil || !strings.Contains(err.Error(), "both named model.safetensors") {
|
||||
t.Errorf("err %v", err)
|
||||
}
|
||||
if ms, err := parseShared(`[{"name":"sf3d","path":"/x","hf_repo":"stabilityai/stable-fast-3d"}]`); err != nil || ms[0].HFRepo == "" {
|
||||
t.Errorf("parse %+v %v", ms, err)
|
||||
}
|
||||
if _, err := parseShared(`[{"name":"sf3d"}]`); err == nil {
|
||||
t.Error("a member with no path was taken")
|
||||
}
|
||||
}
|
||||
@@ -60,7 +60,7 @@ func TestTheGateHearsTheWorkflowsItLetsThrough(t *testing.T) {
|
||||
defer comfy.Close()
|
||||
u, _ := url.Parse(comfy.URL)
|
||||
book := newProgressBook()
|
||||
gate := httptest.NewServer(newGate(u, "", "m", book, "llmgw-gate-1"))
|
||||
gate := httptest.NewServer(newGate(u, "", []string{"m"}, book, "llmgw-gate-1"))
|
||||
defer gate.Close()
|
||||
resp, err := http.Post(gate.URL+"/prompt", "application/json", strings.NewReader(`{"prompt":{},"client_id":"router"}`))
|
||||
if err != nil {
|
||||
|
||||
@@ -161,6 +161,10 @@ type Server struct {
|
||||
// Presets is how the models that come with llmgw reach the catalog.
|
||||
Presets PresetSettings `koanf:"presets"`
|
||||
|
||||
// OnDemand is how models that load when asked for take turns on the
|
||||
// cards.
|
||||
OnDemand OnDemandSettings `koanf:"on_demand"`
|
||||
|
||||
// Agents are the nodes' agent settings: agent.yaml's keys but those an
|
||||
// agent needs to start, for every node (default) and for one
|
||||
// (nodes.<id>). Sent to the agents as they change; an agent whose
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -409,6 +409,10 @@ type Model struct {
|
||||
// say that the repository has published different weights since, and
|
||||
// move the model to them.
|
||||
Origin *Origin `json:"origin,omitempty" koanf:"origin"`
|
||||
|
||||
// Shared is set on an environment's hidden entry only: the members its
|
||||
// replica answers for. Made by the catalog, never written.
|
||||
Shared *SharedComfy `json:"shared,omitempty" koanf:"-"`
|
||||
}
|
||||
|
||||
// RoleGuard is a judge model's role.
|
||||
@@ -650,6 +654,11 @@ const NodesEnv = "LLMGW_COMFYUI_NODES"
|
||||
// HFRepoEnv carries the repository a node loads the weights by name from.
|
||||
const HFRepoEnv = "LLMGW_COMFYUI_HF_REPO"
|
||||
|
||||
// SharedModelsEnv carries a shared replica's members — name, weights source,
|
||||
// repository — to the agent, which fetches each and hands the gate where
|
||||
// they landed.
|
||||
const SharedModelsEnv = "LLMGW_COMFYUI_MODELS"
|
||||
|
||||
var hfRepo = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9][A-Za-z0-9._-]*$`)
|
||||
|
||||
var (
|
||||
@@ -778,6 +787,9 @@ func New(models []Model) (*Catalog, error) {
|
||||
}
|
||||
|
||||
for i := range models {
|
||||
if models[i].IsEnvironment() {
|
||||
continue // the catalog's own, made again below
|
||||
}
|
||||
m := models[i].normalize()
|
||||
if err := m.validate(); err != nil {
|
||||
return nil, err
|
||||
@@ -851,9 +863,25 @@ func New(models []Model) (*Catalog, error) {
|
||||
}
|
||||
canaryOf[target.Name] = name
|
||||
}
|
||||
if _, err := c.environments(); err != nil {
|
||||
envs, err := c.environments()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, e := range envs {
|
||||
if len(e.Models) < 2 {
|
||||
continue
|
||||
}
|
||||
var members []Model
|
||||
for _, n := range e.Models {
|
||||
members = append(members, c.models[n])
|
||||
}
|
||||
em := envModel(e, members)
|
||||
if _, clash := c.models[em.Name]; clash {
|
||||
return nil, fmt.Errorf("model %q: the name is the hidden entry of environment %q", em.Name, e.Name)
|
||||
}
|
||||
c.models[em.Name] = em
|
||||
c.order = append(c.order, em.Name)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
@@ -1297,6 +1325,25 @@ func (m Model) EngineProfile(name string) (engines.Profile, bool) {
|
||||
}
|
||||
env[NodesEnv] = string(nodes)
|
||||
}
|
||||
if p.Engine == EngineComfyUI && m.Shared != nil {
|
||||
// Every member's weights, for the node to fetch and the gate to
|
||||
// link: one replica answers for all of them.
|
||||
type member struct {
|
||||
Name string `json:"name"`
|
||||
Source string `json:"source"`
|
||||
HFRepo string `json:"hf_repo,omitempty"`
|
||||
}
|
||||
var ms []member
|
||||
for _, sm := range m.Shared.Members {
|
||||
ms = append(ms, member{Name: sm.Name, Source: sm.Source, HFRepo: sm.HFRepo})
|
||||
}
|
||||
b, _ := json.Marshal(ms)
|
||||
env = maps.Clone(env)
|
||||
if env == nil {
|
||||
env = map[string]string{}
|
||||
}
|
||||
env[SharedModelsEnv] = string(b)
|
||||
}
|
||||
if p.Engine == EngineComfyUI && m.ComfyUI != nil && m.ComfyUI.HuggingFaceRepo != "" {
|
||||
env = maps.Clone(env)
|
||||
if env == nil {
|
||||
|
||||
@@ -75,7 +75,7 @@ func (c *Catalog) environments() ([]Environment, error) {
|
||||
for _, name := range c.order {
|
||||
m := c.models[name]
|
||||
env := m.ComfyEnvironment()
|
||||
if env == "" {
|
||||
if env == "" || m.IsEnvironment() {
|
||||
continue
|
||||
}
|
||||
if !envName.MatchString(env) {
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// A shared replica: one ComfyUI serving every model of an environment.
|
||||
//
|
||||
// Each ComfyUI model ran in a replica of its own, so on one card a picture
|
||||
// model and a 3D model took turns, each stopping the other and loading
|
||||
// ComfyUI and its weights from nothing. An environment of two or more models
|
||||
// gets a hidden catalog entry of its own, whose replica carries every
|
||||
// member's weights and custom nodes and answers for each of them; the
|
||||
// members get no replicas of their own. Placement, idleness, eviction and
|
||||
// adoption then work as for any model, on that entry.
|
||||
|
||||
// RoleEnvironment is the role of an environment's hidden entry.
|
||||
const RoleEnvironment = "environment"
|
||||
|
||||
// EnvModelPrefix names an environment's hidden entry: comfyui-env-<name>.
|
||||
const EnvModelPrefix = "comfyui-env-"
|
||||
|
||||
// SharedComfy is what an environment's entry carries of its members.
|
||||
type SharedComfy struct {
|
||||
Environment string `json:"environment"`
|
||||
Members []SharedMember `json:"members"`
|
||||
}
|
||||
|
||||
// SharedMember is one model a shared replica answers for.
|
||||
type SharedMember struct {
|
||||
Name string `json:"name"`
|
||||
Aliases []string `json:"aliases,omitempty"`
|
||||
// Source is the member's weights, which the replica's node fetches.
|
||||
Source string `json:"source"`
|
||||
// HFRepo is the repository its node loads the weights by name from.
|
||||
HFRepo string `json:"hf_repo,omitempty"`
|
||||
// Workflow and Checkpoint are what a request for it is sent.
|
||||
Workflow string `json:"workflow"`
|
||||
Checkpoint string `json:"checkpoint"`
|
||||
}
|
||||
|
||||
// IsEnvironment reports whether a model is an environment's hidden entry.
|
||||
func (m Model) IsEnvironment() bool { return m.Role == RoleEnvironment }
|
||||
|
||||
// SharedBy is the hidden entry whose replica serves a model, when it is in
|
||||
// an environment of two or more.
|
||||
func (c *Catalog) SharedBy(model string) (string, bool) {
|
||||
m, ok := c.Resolve(model)
|
||||
if !ok || m.IsEnvironment() || m.ComfyEnvironment() == "" {
|
||||
return "", false
|
||||
}
|
||||
name := EnvModelPrefix + m.ComfyEnvironment()
|
||||
if _, ok := c.models[name]; !ok {
|
||||
return "", false
|
||||
}
|
||||
return name, true
|
||||
}
|
||||
|
||||
// envModel is the hidden entry for an environment of two or more models: on
|
||||
// the first member's profiles, needing the most memory any of them does,
|
||||
// on demand for the longest idle timeout of theirs.
|
||||
func envModel(env Environment, members []Model) Model {
|
||||
first := members[0]
|
||||
profs := make([]Profile, len(first.Profiles))
|
||||
copy(profs, first.Profiles)
|
||||
mem := 0
|
||||
for _, m := range members {
|
||||
for _, p := range m.Profiles {
|
||||
mem = max(mem, p.MinMemoryGB)
|
||||
}
|
||||
}
|
||||
shared := &SharedComfy{Environment: env.Name}
|
||||
var idle time.Duration
|
||||
minN, maxN := 0, 1
|
||||
class := ""
|
||||
for _, m := range members {
|
||||
workflow, ckpt := m.ComfyWorkflow()
|
||||
hf := ""
|
||||
if m.ComfyUI != nil {
|
||||
hf = m.ComfyUI.HuggingFaceRepo
|
||||
}
|
||||
shared.Members = append(shared.Members, SharedMember{Name: m.Name, Aliases: m.Aliases,
|
||||
Source: m.Source, HFRepo: hf, Workflow: workflow, Checkpoint: ckpt})
|
||||
idle = max(idle, m.Scaling.IdleTimeout)
|
||||
minN, maxN = max(minN, m.Scaling.Min), max(maxN, m.Scaling.Max)
|
||||
if ClassRank(m.EffectiveClass()) > ClassRank(class) || class == "" {
|
||||
class = m.EffectiveClass()
|
||||
}
|
||||
}
|
||||
for i := range profs {
|
||||
profs[i].MinMemoryGB = max(profs[i].MinMemoryGB, mem)
|
||||
profs[i].ComfyUI = nil // the environment's nodes, below
|
||||
profs[i].Source = ""
|
||||
}
|
||||
return Model{
|
||||
Name: EnvModelPrefix + env.Name,
|
||||
Role: RoleEnvironment,
|
||||
Description: "the shared ComfyUI replica of environment " + env.Name,
|
||||
Source: first.Source,
|
||||
ComfyUI: &ComfyUI{Environment: env.Name, Nodes: env.Nodes, Output: OutputEnvironment},
|
||||
Profiles: profs,
|
||||
Scaling: Scaling{Min: minN, Max: maxN, IdleTimeout: idle},
|
||||
Class: class,
|
||||
Shared: shared,
|
||||
}
|
||||
}
|
||||
|
||||
// OutputEnvironment is an environment entry's output: it makes nothing of
|
||||
// its own, its members do.
|
||||
const OutputEnvironment = "environment"
|
||||
@@ -0,0 +1,51 @@
|
||||
package catalog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAnEnvironmentOfTwoGetsOneHiddenEntryThatServesBoth(t *testing.T) {
|
||||
// flux and a mesh model on one card took turns, each loading ComfyUI
|
||||
// from nothing; in one environment one replica answers for both.
|
||||
flux := comfyModel("/w/flux.safetensors", &ComfyUI{Environment: "art", Workflow: "flux-schnell"})
|
||||
flux.Scaling = Scaling{Max: 1, IdleTimeout: 15 * time.Minute}
|
||||
sf3d := inEnv("sf3d", "art", "/w/sf3d/", CustomNode{Repo: "https://example.com/sf3d", Commit: commitA})
|
||||
sf3d.ComfyUI.HuggingFaceRepo = "stabilityai/stable-fast-3d"
|
||||
sf3d.ComfyUI.Checkpoint = "model.safetensors"
|
||||
sf3d.Scaling = Scaling{Max: 1, IdleTimeout: 10 * time.Minute}
|
||||
cat, err := New([]Model{flux, sf3d, inEnv("alone", "solo", "/w/alone.ckpt")})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
host, ok := cat.SharedBy("sf3d")
|
||||
if !ok || host != "comfyui-env-art" {
|
||||
t.Fatalf("sf3d shared by %q %v", host, ok)
|
||||
}
|
||||
if _, ok := cat.SharedBy("alone"); ok {
|
||||
t.Error("an environment of one got a hidden entry")
|
||||
}
|
||||
em, _ := cat.Resolve(host)
|
||||
if !em.IsEnvironment() || em.MakesImages() || em.MakesMeshes() || em.Scaling.IdleTimeout != 15*time.Minute ||
|
||||
len(em.Shared.Members) != 2 || em.Shared.Members[1].HFRepo != "stabilityai/stable-fast-3d" {
|
||||
t.Errorf("entry %+v", em)
|
||||
}
|
||||
p, ok := em.EngineProfile(em.Profiles[0].Name)
|
||||
if !ok {
|
||||
t.Fatal("no engine profile")
|
||||
}
|
||||
var ms []map[string]string
|
||||
if err := json.Unmarshal([]byte(p.Env[SharedModelsEnv]), &ms); err != nil || len(ms) != 2 || ms[1]["source"] != "/w/sf3d/" {
|
||||
t.Errorf("members for the agent %q %v", p.Env[SharedModelsEnv], err)
|
||||
}
|
||||
if !strings.Contains(p.Env[NodesEnv], "sf3d") {
|
||||
t.Errorf("nodes %q", p.Env[NodesEnv])
|
||||
}
|
||||
// A catalog built again from its own models is the same catalog.
|
||||
again, err := New(cat.Models())
|
||||
if err != nil || len(again.Models()) != len(cat.Models()) {
|
||||
t.Errorf("rebuilt: %v, %d models vs %d", err, len(again.Models()), len(cat.Models()))
|
||||
}
|
||||
}
|
||||
@@ -371,6 +371,35 @@ func (c *Controller) endpoints() []endpoint.Info {
|
||||
Weight: rep.Weight,
|
||||
Priority: prof.Priority,
|
||||
}
|
||||
if m.Shared != nil {
|
||||
// One replica answering for every member of its environment:
|
||||
// offered under their names, each sent its own workflow and
|
||||
// checkpoint. Its own hidden name nobody asks for.
|
||||
info.Models = nil
|
||||
info.Comfy = map[string]endpoint.ComfyRoute{}
|
||||
ok := true
|
||||
for _, sm := range m.Shared.Members {
|
||||
w, err := c.workflow(sm.Workflow)
|
||||
if err != nil {
|
||||
c.log.Warn("a shared ComfyUI member's workflow could not be read; its replica is not offered",
|
||||
"model", sm.Name, "replica", rep.ID, "err", err)
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
route := endpoint.ComfyRoute{Workflow: w, Checkpoint: sm.Checkpoint}
|
||||
for _, n := range append([]string{sm.Name}, sm.Aliases...) {
|
||||
info.Models = append(info.Models, n)
|
||||
info.Comfy[n] = route
|
||||
}
|
||||
if info.Workflow == "" {
|
||||
info.Workflow, info.UpstreamModel = w, sm.Checkpoint
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
out = append(out, info)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if prof.Engine == catalog.EngineComfyUI {
|
||||
workflow, ckpt := m.ComfyWorkflowFor(prof)
|
||||
w, err := c.workflow(workflow)
|
||||
|
||||
@@ -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,30 @@ func (s *Server) run(j *Job) {
|
||||
}
|
||||
header = v.Header
|
||||
}
|
||||
if header.Embedding {
|
||||
req.Embeddings = true
|
||||
}
|
||||
if req.Embeddings && !req.sized {
|
||||
// Many passages at once rather than four: an import of a hundred
|
||||
// thousand chunks went at the pace of four slots with the card a
|
||||
// third busy.
|
||||
req.ContextLength, req.CtxPool = embedSlotTokens, embedSlotTokens*embedSlots
|
||||
}
|
||||
// On one card, the weights and the context's cache have to fit it: a
|
||||
// 70B with a 65536-token pool on a 24 GB card loaded its weights and then
|
||||
// failed to make its context, every time, as did an 8B with 131072.
|
||||
if len(layout) > 0 && layout[0].Class != "" && !layout[0].CPU {
|
||||
fitted, why, err := fitPoolToCard(req, header, set.Bytes, layout[0].Class, max(layout[0].GPUsPerNode, 1))
|
||||
if err != nil {
|
||||
s.fail(j, "check", err)
|
||||
return
|
||||
}
|
||||
if why != "" {
|
||||
s.step(j, "check", Running, why)
|
||||
req = fitted
|
||||
}
|
||||
}
|
||||
j.Request = req
|
||||
memGB := int((header.MemoryBytes(set.Bytes, req.CtxPool) + 1<<30 - 1) >> 30)
|
||||
basis := fmt.Sprintf("weights %.1f GB + KV cache %.1f GB for %d tokens + engine",
|
||||
gb(set.Bytes), gb(int64(req.CtxPool)*header.KVBytesPerToken()), req.CtxPool)
|
||||
@@ -587,10 +619,24 @@ func profilesFor(layout []modelplan.Option, req Request, memGB, cores int) []cat
|
||||
// slots of 8192, and the gateway, told 32768, sent requests the engine then
|
||||
// refused.
|
||||
func llamaArgs(req Request) []string {
|
||||
return []string{"--ctx-size", strconv.Itoa(req.CtxPool),
|
||||
args := []string{"--ctx-size", strconv.Itoa(req.CtxPool),
|
||||
"--parallel", strconv.Itoa(max(1, req.CtxPool/req.ContextLength))}
|
||||
if req.Embeddings {
|
||||
// A passage is embedded in one batch: with the default 512, one of
|
||||
// 516 tokens was refused.
|
||||
n := strconv.Itoa(req.ContextLength)
|
||||
args = append([]string{"--embedding"}, append(args, "--batch-size", n, "--ubatch-size", n)...)
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// An embedding model's slots, when its caller did not size them: passages
|
||||
// of up to a thousand tokens, thirty-two at a time.
|
||||
const (
|
||||
embedSlotTokens = 1024
|
||||
embedSlots = 32
|
||||
)
|
||||
|
||||
// maxOutput is the longest answer the catalog allows: a quarter of the
|
||||
// context, from 1024 to 8192. 1024 everywhere cut an editor's agent off in
|
||||
// the middle of the file it was writing.
|
||||
@@ -620,7 +666,7 @@ func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.F
|
||||
RoutingProfile: "chat", ContextLength: req.ContextLength, MaxOutput: maxOutput(req.ContextLength),
|
||||
Profiles: profs,
|
||||
Scaling: catalog.Scaling{Min: req.Replicas, Max: max(req.Replicas, 1)},
|
||||
Capabilities: catalog.Capabilities{Vision: req.Projector != "", Tools: req.Tools},
|
||||
Capabilities: catalog.Capabilities{Vision: req.Projector != "", Tools: req.Tools, Embeddings: req.Embeddings},
|
||||
Origin: &catalog.Origin{Repo: repo.ID, Revision: repo.Revision, Files: originFiles(set)},
|
||||
}
|
||||
if req.GuardFormat != "" {
|
||||
@@ -655,7 +701,7 @@ func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.F
|
||||
|
||||
// 6. Does it answer.
|
||||
s.step(j, "smoke test", Running, "asking "+rep.ID+" to say hello")
|
||||
reply, tps, err := s.smoke(ctx, rep, name)
|
||||
reply, tps, err := s.smoke(ctx, rep, name, req.Embeddings)
|
||||
if err != nil {
|
||||
s.fail(j, "smoke test", err)
|
||||
return
|
||||
@@ -859,7 +905,10 @@ func (s *Server) waitReady(ctx context.Context, j *Job, name string, hardware bo
|
||||
}
|
||||
|
||||
// smoke sends one short request straight to the replica, with its engine key.
|
||||
func (s *Server) smoke(ctx context.Context, r reconciler.Replica, name string) (string, float64, error) {
|
||||
func (s *Server) smoke(ctx context.Context, r reconciler.Replica, name string, embeddings bool) (string, float64, error) {
|
||||
if embeddings {
|
||||
return s.smokeEmbeddings(ctx, r, name)
|
||||
}
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"model": name,
|
||||
"max_tokens": 32,
|
||||
@@ -989,3 +1038,77 @@ func pickSafetensors(repo hf.Repo, _ Request) (hf.FileSet, error) {
|
||||
}
|
||||
return set, nil
|
||||
}
|
||||
|
||||
// smokeEmbeddings asks an embedding model for one vector, and says its
|
||||
// length: an embedding model does not chat.
|
||||
func (s *Server) smokeEmbeddings(ctx context.Context, r reconciler.Replica, name string) (string, float64, error) {
|
||||
body, _ := json.Marshal(map[string]any{"model": name, "input": "hello"})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
strings.TrimRight(r.Endpoint, "/")+"/v1/embeddings", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if k := s.deps.Fleet.EngineKey(r.ID); k != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+k)
|
||||
}
|
||||
resp, err := s.deps.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", 0, fmt.Errorf("the replica answered %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||
}
|
||||
var out struct {
|
||||
Data []struct {
|
||||
Embedding []float64 `json:"embedding"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(b, &out); err != nil || len(out.Data) == 0 || len(out.Data[0].Embedding) == 0 {
|
||||
return "", 0, fmt.Errorf("the replica answered no vector: %s", strings.TrimSpace(string(b)))
|
||||
}
|
||||
return fmt.Sprintf("a vector of %d dimensions", len(out.Data[0].Embedding)), 0, nil
|
||||
}
|
||||
|
||||
// embeds is whether the catalog serves name as an embedding model, for a
|
||||
// smoke test of one already there (a canary, an update).
|
||||
func (s *Server) embeds(name string) bool {
|
||||
if s.deps.Current == nil {
|
||||
return false
|
||||
}
|
||||
for _, m := range s.deps.Current() {
|
||||
if m.Name == name {
|
||||
return m.Capabilities.Embeddings
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fitPoolToCard shrinks a context pool the cards cannot hold to one they
|
||||
// can, saying so, and refuses weights that do not fit at all. The slots stay
|
||||
// as many as asked while each keeps 2048 tokens, then fewer.
|
||||
func fitPoolToCard(req Request, h gguf.Header, weights int64, class string, gpus int) (Request, string, error) {
|
||||
maxTok := h.PoolThatFits(weights, gguf.ClassMemGB(class), gpus)
|
||||
switch {
|
||||
case maxTok == 0 || req.CtxPool <= maxTok:
|
||||
return req, "", nil
|
||||
case maxTok < 0:
|
||||
return req, "", fmt.Errorf("its weights (%.1f GB) do not fit %d card(s) of %s with room to run: "+
|
||||
"choose a smaller quantisation, or more cards", gb(weights), gpus, class)
|
||||
}
|
||||
slots := max(1, req.CtxPool/max(req.ContextLength, 1))
|
||||
for slots > 1 && maxTok/slots < 2048 {
|
||||
slots--
|
||||
}
|
||||
per := (maxTok / slots) / 1024 * 1024
|
||||
if per < 1024 {
|
||||
return req, "", fmt.Errorf("its weights (%.1f GB) leave room on %s for a context of %d tokens only",
|
||||
gb(weights), class, maxTok)
|
||||
}
|
||||
was := req.CtxPool
|
||||
req.ContextLength, req.CtxPool = per, per*slots
|
||||
return req, fmt.Sprintf("a %d-token pool does not fit %s beside %.1f GB of weights: %d slots of %d tokens instead",
|
||||
was, class, gb(weights), slots, per), nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/gguf"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
@@ -397,3 +398,33 @@ func TestOnlyTheLastFinishedJobsAreRemembered(t *testing.T) {
|
||||
t.Error("the oldest finished job is still remembered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmbeddingModelIsServedForEmbedding(t *testing.T) {
|
||||
// Four slots and a 512-token batch: an import went at four passages a
|
||||
// pass, and one passage of 516 tokens was refused.
|
||||
args := strings.Join(llamaArgs(Request{Embeddings: true, ContextLength: embedSlotTokens,
|
||||
CtxPool: embedSlotTokens * embedSlots}), " ")
|
||||
for _, want := range []string{"--embedding", "--ctx-size 32768", "--parallel 32", "--batch-size 1024", "--ubatch-size 1024"} {
|
||||
if !strings.Contains(args, want) {
|
||||
t.Errorf("%q not in %q", want, args)
|
||||
}
|
||||
}
|
||||
if args := strings.Join(llamaArgs(Request{ContextLength: 4096, CtxPool: 16384}), " "); strings.Contains(args, "embedding") {
|
||||
t.Errorf("a chat model got %q", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPoolTheCardCannotHoldIsShrunkNotStarted(t *testing.T) {
|
||||
h := gguf.Header{Architecture: "apertus", BlockCount: 32, HeadCount: 32, HeadCountKV: 8, EmbeddingLength: 4096}
|
||||
req := Request{CtxPool: 131072, ContextLength: 32768}
|
||||
got, why, err := fitPoolToCard(req, h, int64(8.5*(1<<30)), "6000-24g", 1)
|
||||
if err != nil || why == "" || got.CtxPool > 105000 || got.CtxPool/got.ContextLength != 4 {
|
||||
t.Errorf("fitted %+v %q %v", got, why, err)
|
||||
}
|
||||
if _, _, err := fitPoolToCard(req, h, int64(30*(1<<30)), "6000-24g", 1); err == nil {
|
||||
t.Error("weights larger than the card were not refused")
|
||||
}
|
||||
if got, why, _ := fitPoolToCard(Request{CtxPool: 16384, ContextLength: 4096}, h, int64(8.5*(1<<30)), "6000-24g", 1); why != "" || got.CtxPool != 16384 {
|
||||
t.Errorf("a pool that fits was changed: %+v %q", got, why)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -55,7 +55,7 @@ func (r *Reconciler) IdleFor(id string) (time.Duration, bool) {
|
||||
// from "there is genuinely nothing serving this", which are different answers
|
||||
// to give a client.
|
||||
func (r *Reconciler) Want(model string) bool {
|
||||
m, ok := r.cat.Load().Resolve(model)
|
||||
m, ok := r.cat.Load().Resolve(r.hostOf(model))
|
||||
if !ok || !m.Scaling.OnDemand() {
|
||||
return false
|
||||
}
|
||||
@@ -127,6 +127,9 @@ func (r *Reconciler) baseFor(m catalog.Model) int {
|
||||
if m.IsExternal() {
|
||||
return 0 // served elsewhere: nothing to place, whatever its scaling says
|
||||
}
|
||||
if _, shared := r.cat.Load().SharedBy(m.Name); shared {
|
||||
return 0 // its environment's replica serves it
|
||||
}
|
||||
if lo := r.minFor(m); !m.Scaling.OnDemand() || lo > 0 {
|
||||
return lo
|
||||
}
|
||||
@@ -199,6 +202,17 @@ type Load struct {
|
||||
// were measured to hold, so scaling down does not land on the edge.
|
||||
func (r *Reconciler) RecordLoad(loads map[string]Load) {
|
||||
now := r.now()
|
||||
// A shared replica's load is its members' together.
|
||||
for name, l := range loads {
|
||||
if host := r.hostOf(name); host != name {
|
||||
h := loads[host]
|
||||
h.Replicas = max(h.Replicas, l.Replicas)
|
||||
h.Running += l.Running
|
||||
h.Waiting += l.Waiting
|
||||
loads[host] = h
|
||||
delete(loads, name)
|
||||
}
|
||||
}
|
||||
// The profile each model's replicas run on, for its measurement; read
|
||||
// before the loop, which holds the lock.
|
||||
running := map[string]string{}
|
||||
@@ -280,3 +294,12 @@ func (r *Reconciler) RecordLoad(loads map[string]Load) {
|
||||
// after a scale-down may be. Below one so that the load that was there a
|
||||
// minute ago does not put them straight back over.
|
||||
const scaleDownHeadroom = 0.7
|
||||
|
||||
// hostOf is the model whose replicas serve name: its environment's hidden
|
||||
// entry when it shares one, else itself.
|
||||
func (r *Reconciler) hostOf(name string) string {
|
||||
if host, ok := r.cat.Load().SharedBy(name); ok {
|
||||
return host
|
||||
}
|
||||
return name
|
||||
}
|
||||
@@ -88,6 +88,8 @@ func (r *Reconciler) forgetWantIfGone(model, stopped string) {
|
||||
// longest idle first.
|
||||
func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
|
||||
cat := r.cat.Load()
|
||||
// A member never evicts the replica that serves it.
|
||||
wantedBy = r.hostOf(wantedBy)
|
||||
now := r.now()
|
||||
|
||||
wantRank := -1
|
||||
@@ -120,7 +122,7 @@ func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
|
||||
continue
|
||||
}
|
||||
idle := now.Sub(rep.LastUsedAt)
|
||||
if idle < r.cfg.EvictionGrace {
|
||||
if idle < r.evictionGrace() {
|
||||
continue
|
||||
}
|
||||
// A replica that has only just started is left alone however idle it
|
||||
@@ -128,7 +130,7 @@ func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
|
||||
// otherwise take turns evicting each other, paying a model load each
|
||||
// time — and a load is expensive enough that waiting is strictly
|
||||
// better than winning.
|
||||
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.cfg.MinLifetime {
|
||||
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.minLifetime() {
|
||||
continue
|
||||
}
|
||||
nodes := map[string]struct{}{}
|
||||
@@ -156,3 +158,34 @@ func (r *Reconciler) EvictionReason(model string) string {
|
||||
}
|
||||
return "nothing idle enough to stop"
|
||||
}
|
||||
|
||||
// evictionGrace is how long an on-demand replica must go unasked-for before
|
||||
// another model may have its card: the cluster's setting, else the
|
||||
// configuration's.
|
||||
func (r *Reconciler) evictionGrace() time.Duration {
|
||||
if d := time.Duration(r.evictGrace.Load()); d > 0 {
|
||||
return d
|
||||
}
|
||||
return r.cfg.EvictionGrace
|
||||
}
|
||||
|
||||
// minLifetime is how long a replica is safe from eviction once it is up.
|
||||
func (r *Reconciler) minLifetime() time.Duration {
|
||||
if d := time.Duration(r.minLife.Load()); d > 0 {
|
||||
return d
|
||||
}
|
||||
return r.cfg.MinLifetime
|
||||
}
|
||||
|
||||
// SetEvictionTimes puts the cluster's on_demand settings in force: how long
|
||||
// an idle on-demand replica keeps its card, and how long a new one is safe.
|
||||
// Zero keeps the configuration's.
|
||||
//
|
||||
// On one card shared by several models these are the wait between them: a
|
||||
// 3D job after a picture waited out five minutes of a picture model it no
|
||||
// longer needed.
|
||||
func (r *Reconciler) SetEvictionTimes(afterIdle, minLifetime time.Duration) {
|
||||
r.evictGrace.Store(int64(afterIdle))
|
||||
r.minLife.Store(int64(minLifetime))
|
||||
r.Notify()
|
||||
}
|
||||
@@ -130,7 +130,7 @@ func (r *Reconciler) ModelHealth() []ModelHealth {
|
||||
// Unservable says why a model cannot be served at all, so a request for it is
|
||||
// told now rather than asked to retry something that will never come up.
|
||||
func (r *Reconciler) Unservable(model string) (string, bool) {
|
||||
m, ok := r.cat.Load().Resolve(model)
|
||||
m, ok := r.cat.Load().Resolve(r.hostOf(model))
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func (r *Reconciler) Unservable(model string) (string, bool) {
|
||||
// on another model; eta is zero when when the holder lets go is not known.
|
||||
func (r *Reconciler) WaitFor(model string) (heldBy string, eta time.Duration, ok bool) {
|
||||
cat := r.cat.Load()
|
||||
m, found := cat.Resolve(model)
|
||||
m, found := cat.Resolve(r.hostOf(model))
|
||||
if !found || !m.Scaling.OnDemand() {
|
||||
return "", 0, false
|
||||
}
|
||||
@@ -205,9 +205,9 @@ func (r *Reconciler) WaitFor(model string) (heldBy string, eta time.Duration, ok
|
||||
// Evicted sooner, when it is no higher a class than the model
|
||||
// waiting; one put first is waited for to its idle timeout.
|
||||
if catalog.ClassRank(other.EffectiveClass()) <= catalog.ClassRank(m.EffectiveClass()) {
|
||||
evict := rep.LastUsedAt.Add(r.cfg.EvictionGrace)
|
||||
if !rep.ReadyAt.IsZero() && rep.ReadyAt.Add(r.cfg.MinLifetime).After(evict) {
|
||||
evict = rep.ReadyAt.Add(r.cfg.MinLifetime)
|
||||
evict := rep.LastUsedAt.Add(r.evictionGrace())
|
||||
if !rep.ReadyAt.IsZero() && rep.ReadyAt.Add(r.minLifetime()).After(evict) {
|
||||
evict = rep.ReadyAt.Add(r.minLifetime())
|
||||
}
|
||||
if evict.Before(h.release) {
|
||||
h.release = evict
|
||||
|
||||
@@ -249,7 +249,7 @@ func (r *Reconciler) victims(m catalog.Model) []victim {
|
||||
aboveMin := i < above
|
||||
tier := -1
|
||||
switch {
|
||||
case rank < myRank && other.Scaling.OnDemand() && idleFor(rep, now) >= r.cfg.EvictionGrace:
|
||||
case rank < myRank && other.Scaling.OnDemand() && idleFor(rep, now) >= r.evictionGrace():
|
||||
tier = 0
|
||||
case rank < myRank && aboveMin:
|
||||
tier = 2
|
||||
@@ -275,7 +275,7 @@ func (r *Reconciler) victims(m catalog.Model) []victim {
|
||||
}
|
||||
// Just started: stopping it throws away a model load that has
|
||||
// only now been paid for.
|
||||
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.cfg.MinLifetime {
|
||||
if !rep.ReadyAt.IsZero() && now.Sub(rep.ReadyAt) < r.minLifetime() {
|
||||
continue
|
||||
}
|
||||
if len(nodesOf(rep)) > 1 {
|
||||
|
||||
@@ -264,6 +264,9 @@ type Reconciler struct {
|
||||
// because it is state about this moment and not about what the model is.
|
||||
// A model nobody has wanted has no entry and is simply not running.
|
||||
wanted map[string]time.Time
|
||||
// evictGrace and minLife are the cluster's on_demand settings, as
|
||||
// durations; zero keeps cfg's.
|
||||
evictGrace, minLife atomic.Int64
|
||||
// coldStart is how long each model's last replica took from placed to
|
||||
// ready, for telling a request waiting on it about how long it waits.
|
||||
coldStart map[string]time.Duration
|
||||
|
||||
@@ -1763,6 +1763,31 @@ func mustResolve(t *testing.T, h *harness, name string) catalog.Model {
|
||||
return m
|
||||
}
|
||||
|
||||
func TestTheClustersOnDemandSettingsShortenTheWaitForACard(t *testing.T) {
|
||||
// One card, a picture model and a 3D model: a mesh after a picture
|
||||
// waited out five minutes of the picture model's protection.
|
||||
h := newHarness(t, testModel(1), gpuNode("1", 4))
|
||||
h.r.cat.Store(twoOnDemandModels(t))
|
||||
h.r.Want("llama")
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
reps := h.r.Replicas()
|
||||
h.r.RecordUsage(map[string]time.Time{reps[0].ID: h.clock.now()})
|
||||
h.advance(90 * time.Second)
|
||||
if got := h.r.evictable("second"); len(got) != 0 {
|
||||
t.Fatalf("offered at 90 s with the defaults: %+v", got)
|
||||
}
|
||||
h.r.SetEvictionTimes(30*time.Second, time.Minute)
|
||||
if got := h.r.evictable("second"); len(got) != 1 {
|
||||
t.Errorf("not offered at 90 s with 30 s idle and 1 min lifetime: %+v", got)
|
||||
}
|
||||
// Zero goes back to the configuration's.
|
||||
h.r.SetEvictionTimes(0, 0)
|
||||
if got := h.r.evictable("second"); len(got) != 0 {
|
||||
t.Errorf("offered after the settings were cleared: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFreshlyStartedReplicaIsNotEvicted(t *testing.T) {
|
||||
// What stops two models on a cluster with room for one from taking turns
|
||||
// evicting each other, paying a model load each time. A load is expensive
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package reconciler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
)
|
||||
|
||||
func sharedCatalog(t *testing.T) *catalog.Catalog {
|
||||
t.Helper()
|
||||
mk := func(name, out string) catalog.Model {
|
||||
return catalog.Model{Name: name, Source: "/w/" + name + ".safetensors",
|
||||
ComfyUI: &catalog.ComfyUI{Environment: "art", Workflow: "flux-schnell", Output: out},
|
||||
Scaling: catalog.Scaling{Max: 1, IdleTimeout: time.Hour},
|
||||
Profiles: []catalog.Profile{{Name: "p", Engine: catalog.EngineComfyUI, GPUClass: "h100-80g", TP: 1}}}
|
||||
}
|
||||
cat, err := catalog.New([]catalog.Model{mk("flux", catalog.OutputPictures), mk("sf3d", catalog.OutputMeshes)})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return cat
|
||||
}
|
||||
|
||||
func TestAMemberAskedForStartsItsEnvironmentsOneReplica(t *testing.T) {
|
||||
// A picture model and a 3D model on one card took turns, each from
|
||||
// nothing: in one environment, asking for either starts one replica
|
||||
// that serves both, and neither gets one of its own.
|
||||
h := newHarness(t, testModel(1), gpuNode("1", 1))
|
||||
h.r.cat.Store(sharedCatalog(t))
|
||||
if !h.r.Want("sf3d") {
|
||||
t.Fatal("a member was not wanted")
|
||||
}
|
||||
h.r.Reconcile(t.Context())
|
||||
h.markReady()
|
||||
h.r.Want("flux")
|
||||
h.r.Reconcile(t.Context())
|
||||
var models []string
|
||||
for _, rep := range h.r.Replicas() {
|
||||
if rep.State.Counts() {
|
||||
models = append(models, rep.Model)
|
||||
}
|
||||
}
|
||||
if len(models) != 1 || models[0] != "comfyui-env-art" {
|
||||
t.Fatalf("replicas %v", models)
|
||||
}
|
||||
// The member's sibling never evicts the replica serving it.
|
||||
reps := h.r.Replicas()
|
||||
h.r.RecordUsage(map[string]time.Time{reps[0].ID: h.clock.now()})
|
||||
h.advance(DefaultConfig().MinLifetime + DefaultConfig().EvictionGrace + time.Minute)
|
||||
if got := h.r.evictable("flux"); len(got) != 0 {
|
||||
t.Errorf("a member was offered its own environment's replica: %+v", got)
|
||||
}
|
||||
if _, _, ok := h.r.WaitFor("flux"); ok {
|
||||
t.Error("a member served by a replica that is up was said to be waiting")
|
||||
}
|
||||
}
|
||||
@@ -260,6 +260,10 @@ var Reference = []Block{
|
||||
{Key: "mirror", Type: "string", Doc: "Mirror is the mirror's file address, as nodes reach it:\nhttp://10.0.0.5:8081 — the port model files are served on, not the\nmanagement API. Set, the cluster is offline: nodes install engines\nfrom it and never from their publishers, releases come from it unless\nreleases names another place, and no registry is asked whether a\nmodel has newer weights. Python engines (vLLM, SGLang) come from PyPI,\nwhich no bundle carries; on an offline node they are installed by hand.", Nested: ""},
|
||||
{Key: "checksums", Type: "string", Doc: "Checksums is the file of engine checksums this control plane trusts,\nkept by llmgw bundle import and llmgw bundle trust; every engine file\na node fetches from the mirror is checked against it. Read again\nwhenever it changes. Empty is /etc/llmgw/offline.sha256.", Nested: ""},
|
||||
}},
|
||||
{Package: "config", Name: "OnDemandSettings", Title: "OnDemandSettings", Doc: "", Fields: []Field{
|
||||
{Key: "evict_after_idle", Type: "time.Duration", Doc: "EvictAfterIdle is how long an on-demand replica must go unasked-for\nbefore a model that wants its card may have it. Zero keeps 2m.", Nested: ""},
|
||||
{Key: "min_lifetime", Type: "time.Duration", Doc: "MinLifetime is how long a replica that has just come up is safe from\nthat, so two models do not take turns paying for loads. Zero keeps\n5m. On one card shared by a picture model and a 3D model, 1m each\nbrings the wait between a picture and its mesh to about a minute.\nBoth are in force on the leader within seconds.", Nested: ""},
|
||||
}},
|
||||
{Package: "discovery", Name: "Options", Title: "Options", Doc: "", Fields: []Field{
|
||||
{Key: "nvidia_smi", Type: "string", Doc: "NvidiaSMI is the path to nvidia-smi; empty means look it up on PATH.\nWorth configuring on nodes where the driver lives outside the default\npaths, which containerized driver installs often do.", Nested: ""},
|
||||
{Key: "ib_root", Type: "string", Doc: "IBRoot is the sysfs InfiniBand directory.", Nested: ""},
|
||||
@@ -442,6 +446,7 @@ var Reference = []Block{
|
||||
{Key: "serving", Type: "ServingSettings", Doc: "Serving stops and starts the whole cluster's service (llmgw stop, llmgw\nstart).", Nested: "config.ServingSettings"},
|
||||
{Key: "firewall", Type: "FirewallSettings", Doc: "Firewall turns llmgw's firewall on every machine on or off.", Nested: "config.FirewallSettings"},
|
||||
{Key: "presets", Type: "PresetSettings", Doc: "Presets is how the models that come with llmgw reach the catalog.", Nested: "config.PresetSettings"},
|
||||
{Key: "on_demand", Type: "OnDemandSettings", Doc: "OnDemand is how models that load when asked for take turns on the\ncards.", Nested: "config.OnDemandSettings"},
|
||||
{Key: "agents", Type: "AgentSettings", Doc: "Agents are the nodes' agent settings: agent.yaml's keys but those an\nagent needs to start, for every node (default) and for one\n(nodes.<id>). Sent to the agents as they change; an agent whose\nsettings changed starts again on them, its engines serving\nthroughout.", Nested: "config.AgentSettings"},
|
||||
{Key: "firewall_networks", Type: "[]string", Doc: "FirewallNetworks are networks, besides those each machine reaches the\ncluster on, that llmgw's internal ports answer: a monitoring host on\nanother subnet, nodes behind a router. Written into every machine's\nfirewall by `server init`, `node add` and `server add`.", Nested: ""},
|
||||
{Key: "backup_recipients", Type: "[]string", Doc: "BackupRecipients encrypt every archive `llmgw server backup` writes, to\nwhoever holds one of their keys: age recipients (age1…) or SSH public\nkeys (ssh-ed25519 …, ssh-rsa …), as `age` takes them. An archive holds\nthe certificate authority's key and every secret in the store, and is\ncopied off the machine; encrypted, the copy is safe wherever it lands.\nEmpty writes archives unencrypted, readable by root only.", Nested: ""},
|
||||
|
||||
@@ -36,6 +36,7 @@ var Sources = []string{
|
||||
"internal/config/firewall.go",
|
||||
"internal/config/serving.go",
|
||||
"internal/config/presets.go",
|
||||
"internal/config/ondemand.go",
|
||||
"internal/config/agents.go",
|
||||
"internal/mail/mail.go",
|
||||
"internal/notify/notify.go",
|
||||
|
||||
@@ -41,6 +41,10 @@ type Header struct {
|
||||
// them; otherwise a head is EmbeddingLength / HeadCount.
|
||||
KeyLength uint64
|
||||
ValueLength uint64
|
||||
// Embedding is whether the model pools its tokens into one vector
|
||||
// (<arch>.pooling_type set and not none): an embedding model, which
|
||||
// llama.cpp serves with --embedding and does not chat.
|
||||
Embedding bool
|
||||
}
|
||||
|
||||
// KVBytesPerToken is what one token of context costs in llama.cpp's default
|
||||
@@ -265,6 +269,8 @@ func Read(r io.Reader) (Header, error) {
|
||||
h.KeyLength = num
|
||||
case "attention.value_length":
|
||||
h.ValueLength = num
|
||||
case "pooling_type":
|
||||
h.Embedding = num > 0
|
||||
}
|
||||
}
|
||||
// The tokenizer's arrays — megabytes of vocabulary — come after the
|
||||
@@ -372,3 +378,40 @@ func Check(ctx context.Context, url, llamaVersion string) Verdict {
|
||||
v.Header = h
|
||||
return v
|
||||
}
|
||||
|
||||
// CardUsable is the share of a card's memory a model may take: the rest is
|
||||
// CUDA's own and the engine's compute buffers.
|
||||
const CardUsable = 0.9
|
||||
|
||||
// PoolThatFits is the largest context pool, in tokens, whose KV cache fits
|
||||
// on gpus cards of cardGB beside weights bytes of weights and a gigabyte for
|
||||
// the engine: -1 when the weights alone do not, 0 when the header or the
|
||||
// card does not say enough to tell.
|
||||
func (h Header) PoolThatFits(weights int64, cardGB, gpus int) int {
|
||||
perTok := h.KVBytesPerToken()
|
||||
if cardGB <= 0 || perTok <= 0 || weights <= 0 {
|
||||
return 0
|
||||
}
|
||||
budget := float64(cardGB*max(gpus, 1))*CardUsable*(1<<30) - float64(weights) - (1 << 30)
|
||||
if budget <= 0 {
|
||||
return -1
|
||||
}
|
||||
return int(budget / float64(perTok))
|
||||
}
|
||||
|
||||
// ClassMemGB is a card's memory as its class names it: 6000-24g is 24, 0
|
||||
// for a class that does not say.
|
||||
func ClassMemGB(class string) int {
|
||||
i := strings.LastIndex(class, "-")
|
||||
if i < 0 || !strings.HasSuffix(class, "g") {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, c := range strings.TrimSuffix(class[i+1:], "g") {
|
||||
if c < '0' || c > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
}
|
||||
return n
|
||||
}
|
||||
@@ -163,3 +163,50 @@ func TestArraysNestedDeepAreRefused(t *testing.T) {
|
||||
t.Errorf("ten thousand nested arrays: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmbeddingModelIsKnownByItsPooling(t *testing.T) {
|
||||
build := func(pooling uint32) []byte {
|
||||
var b bytes.Buffer
|
||||
w := func(v any) { _ = binary.Write(&b, binary.LittleEndian, v) }
|
||||
str := func(s string) { w(uint64(len(s))); b.WriteString(s) }
|
||||
u32 := func(k string, v uint32) { str(k); w(uint32(tUint32)); w(v) }
|
||||
b.WriteString("GGUF")
|
||||
w(uint32(3))
|
||||
w(uint64(0))
|
||||
w(uint64(3))
|
||||
str("general.architecture")
|
||||
w(uint32(tString))
|
||||
str("bert")
|
||||
u32("bert.block_count", 24)
|
||||
u32("bert.pooling_type", pooling)
|
||||
return b.Bytes()
|
||||
}
|
||||
if h, err := Read(bytes.NewReader(build(2))); err != nil || !h.Embedding {
|
||||
t.Errorf("CLS pooling: %+v %v", h, err)
|
||||
}
|
||||
if h, err := Read(bytes.NewReader(build(0))); err != nil || h.Embedding {
|
||||
t.Errorf("no pooling: %+v %v", h, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPoolIsSizedToTheCard(t *testing.T) {
|
||||
// Apertus-8B's shape: 32 layers, 8 KV heads of 128, 128 KiB a token.
|
||||
h, err := Read(bytes.NewReader(shapedHeader("apertus", 32, 32, 8, 4096, true)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
weights := int64(8.5 * (1 << 30))
|
||||
most := h.PoolThatFits(weights, ClassMemGB("6000-24g"), 1)
|
||||
if most < 90000 || most > 105000 {
|
||||
t.Errorf("an 8B on 24 GB holds %d tokens of pool", most)
|
||||
}
|
||||
if most >= 131072 {
|
||||
t.Error("the 131072 pool that failed on the card would pass")
|
||||
}
|
||||
if got := h.PoolThatFits(int64(30*(1<<30)), 24, 1); got != -1 {
|
||||
t.Errorf("30 GB of weights on 24 GB: %d", got)
|
||||
}
|
||||
if ClassMemGB("t4-15g") != 15 || ClassMemGB("cpu") != 0 || ClassMemGB("v100-32g") != 32 {
|
||||
t.Error("class memory misread")
|
||||
}
|
||||
}
|
||||
@@ -258,7 +258,11 @@ type Result struct {
|
||||
|
||||
// redact keeps a webhook's host and drops its path, where Slack and others
|
||||
// put the secret.
|
||||
func redact(u string) string {
|
||||
func redact(u string) string { return Redact(u) }
|
||||
|
||||
// Redact is a webhook's address as it may be shown: its host, not the path
|
||||
// that is its secret.
|
||||
func Redact(u string) string {
|
||||
if i := strings.Index(u, "://"); i >= 0 {
|
||||
if j := strings.IndexByte(u[i+3:], '/'); j >= 0 {
|
||||
return u[:i+3+j] + "/…"
|
||||
|
||||
@@ -111,7 +111,7 @@ func (r *Router) isCanaryTarget(name string) bool {
|
||||
|
||||
// hiddenModel is a model clients cannot name: a judge, or a canary.
|
||||
func (r *Router) hiddenModel(name string) bool {
|
||||
return r.isGuardModel(name) || r.isCanaryTarget(name)
|
||||
return r.isGuardModel(name) || r.isCanaryTarget(name) || r.modelMap()[name].IsEnvironment()
|
||||
}
|
||||
|
||||
// setVariantHeader tells the client which side served it.
|
||||
|
||||
@@ -51,6 +51,25 @@ type Info struct {
|
||||
// Workflow is the ComfyUI workflow sent for an endpoint whose engine is
|
||||
// EngineComfyUI: a built-in's name or the workflow's JSON.
|
||||
Workflow string
|
||||
// Comfy is, for a shared ComfyUI replica that answers for several
|
||||
// models, each one's workflow and checkpoint, by every name it goes by.
|
||||
// Nil for a replica of one model, which Workflow and UpstreamModel say.
|
||||
Comfy map[string]ComfyRoute
|
||||
}
|
||||
|
||||
// ComfyRoute is what a request for one model of a shared ComfyUI replica is
|
||||
// sent.
|
||||
type ComfyRoute struct {
|
||||
Workflow string
|
||||
Checkpoint string
|
||||
}
|
||||
|
||||
// ComfyFor is the workflow and checkpoint a request for model is sent here.
|
||||
func (i Info) ComfyFor(model string) (workflow, checkpoint string) {
|
||||
if r, ok := i.Comfy[model]; ok {
|
||||
return r.Workflow, r.Checkpoint
|
||||
}
|
||||
return i.Workflow, i.UpstreamModel
|
||||
}
|
||||
|
||||
// EngineExternal marks an endpoint that is a provider outside the cluster.
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
@@ -20,8 +21,16 @@ import (
|
||||
const (
|
||||
purposeBatch = "batch"
|
||||
purposeBatchOutput = "batch_output"
|
||||
// purposeVision and purposeUserData are files a request works from — a
|
||||
// picture to make a mesh of or to look at, a workflow's input — named
|
||||
// by id rather than sent as base64 in every call. OpenAI's names.
|
||||
purposeVision = "vision"
|
||||
purposeUserData = "user_data"
|
||||
)
|
||||
|
||||
// uploadPurposes are what a client may upload a file for.
|
||||
var uploadPurposes = []string{purposeBatch, purposeVision, purposeUserData}
|
||||
|
||||
func newID(prefix string) string {
|
||||
var b [12]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
@@ -99,9 +108,11 @@ func (r *Router) handleUploadFile(w http.ResponseWriter, req *http.Request) {
|
||||
fmt.Sprintf("a multipart form with purpose and file, at most %d MB: %v", maxBytes>>20, err))
|
||||
return
|
||||
}
|
||||
if purpose := req.FormValue("purpose"); purpose != purposeBatch {
|
||||
purpose := req.FormValue("purpose")
|
||||
if !slices.Contains(uploadPurposes, purpose) {
|
||||
writeError(w, http.StatusBadRequest, "invalid_request_error",
|
||||
fmt.Sprintf("purpose %q: this gateway keeps files for batches only (purpose batch)", purpose))
|
||||
fmt.Sprintf("purpose %q: this gateway keeps files for batches (batch) and for requests to work from "+
|
||||
"(vision, user_data)", purpose))
|
||||
return
|
||||
}
|
||||
file, header, err := req.FormFile("file")
|
||||
@@ -120,8 +131,13 @@ func (r *Router) handleUploadFile(w http.ResponseWriter, req *http.Request) {
|
||||
fmt.Sprintf("the file is larger than %d MB", maxBytes>>20))
|
||||
return
|
||||
}
|
||||
f := store.File{ID: newID("file-"), Owner: ownerOf(p), Purpose: purposeBatch,
|
||||
f := store.File{ID: newID("file-"), Owner: ownerOf(p), Purpose: purpose,
|
||||
Filename: header.Filename, Bytes: int64(len(content)), CreatedAt: time.Now()}
|
||||
// A file to work from is kept as long as what is made from it.
|
||||
if purpose != purposeBatch {
|
||||
_, _, _, days := r.batchCfg()
|
||||
f.ExpiresAt = f.CreatedAt.AddDate(0, 0, days)
|
||||
}
|
||||
if err := r.store.PutFile(req.Context(), f, content); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "server_error", err.Error())
|
||||
return
|
||||
|
||||
@@ -359,7 +359,9 @@ func (r *Router) Handler() http.Handler {
|
||||
}
|
||||
|
||||
if r.auth == nil {
|
||||
return r.refuseWhileStopped(r.refuseLoops(mux))
|
||||
h := r.refuseWhileStopped(r.refuseLoops(mux))
|
||||
r.selfHandler.Store(&h)
|
||||
return h
|
||||
}
|
||||
exempt := []string{"/health", "/ready", "/leader", "/v1/openapi.json", "/", "/about", "/_static/", "/_lang/", "/claim/",
|
||||
"/signup", "/login", "/login/", "/logout", "/profile", "/profile/", "/scim/",
|
||||
@@ -373,7 +375,9 @@ func (r *Router) Handler() http.Handler {
|
||||
// Wrapping the whole mux rather than checking inside each handler: adding a
|
||||
// route must not be able to add an unauthenticated one by omission. Health
|
||||
// stays open because a load balancer has no key.
|
||||
return r.refuseWhileStopped(r.refuseLoops(auth.Middleware(r.auth, r.log, exempt...)(mux)))
|
||||
h := r.refuseWhileStopped(r.refuseLoops(auth.Middleware(r.auth, r.log, exempt...)(mux)))
|
||||
r.selfHandler.Store(&h)
|
||||
return h
|
||||
}
|
||||
|
||||
// refuseWhileStopped answers every request for a model with 503 while the
|
||||
|
||||
@@ -182,7 +182,8 @@ func (r *Router) generateImages(ctx context.Context, w http.ResponseWriter, req
|
||||
Model string `json:"model"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &upstream)
|
||||
tpl, err := comfy.Load(ep.Workflow)
|
||||
wf, _ := ep.ComfyFor(req.Model)
|
||||
tpl, err := comfy.Load(wf)
|
||||
if err != nil {
|
||||
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
|
||||
}
|
||||
|
||||
@@ -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,980 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
_ "image/jpeg" // a picture sent to look at may be a JPEG
|
||||
"image/png"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
|
||||
)
|
||||
|
||||
// --- MCP: pictures, 3D models, looking at pictures, workflows ---
|
||||
//
|
||||
// The gateway's own models offered as tools, so an agent — Claude Code
|
||||
// driving Blender, say — makes a concept, a mesh from it and has a render
|
||||
// looked at without a tool server of its own. Each tool makes its request
|
||||
// of the gateway as the caller would, under the caller's key: the same
|
||||
// allowlist, limits, licences left out, guardrails and accounting. What it
|
||||
// makes is kept as a file of the caller's, said by id and by the address
|
||||
// it is fetched from with the same key, since a tool server over HTTP
|
||||
// cannot write to the caller's disk.
|
||||
|
||||
const (
|
||||
// mcpBody bounds a tool call: up to four pictures to look at, as base64.
|
||||
mcpBody = 64 << 20
|
||||
// previewEdge is the long side of the picture shown to the agent beside
|
||||
// the file: enough to see it, not a megapixel of tokens.
|
||||
previewEdge = 512
|
||||
// lookEdge is the long side of a picture sent to a vision model:
|
||||
// 1280x720 is about 940 tokens.
|
||||
lookEdge = 1280
|
||||
// mcpWaitStreamed is how long a call answered as a stream waits for its
|
||||
// job, saying how far along it is meanwhile; mcpWaitPlain how long one
|
||||
// answered in one piece does, under the minute a proxy allows.
|
||||
mcpWaitStreamed = 10 * time.Minute
|
||||
mcpWaitPlain = 50 * time.Second
|
||||
mcpWaitMax = 15 * time.Minute
|
||||
mcpPoll = 2 * time.Second
|
||||
)
|
||||
|
||||
// The prompt endings of generate_image's styles: what a picture a mesh is
|
||||
// made from wants, and a surface that tiles.
|
||||
var imageStyles = map[string]string{
|
||||
"none": "",
|
||||
"concept": ", single object centered, orthographic three-quarter view, flat even studio lighting, no shadows, " +
|
||||
"no background elements, plain flat grey background, clean silhouette, game asset concept, high detail, no text, no watermark",
|
||||
"side": ", single object, exact side view (profile), orthographic, flat even studio lighting, no shadows, " +
|
||||
"plain flat grey background, clean silhouette, game asset concept, high detail, no text, no watermark",
|
||||
"texture": ", seamless tileable texture, top-down orthographic, flat even lighting, no shadows, no perspective, no text",
|
||||
}
|
||||
|
||||
// mcpMediaInstructions is said to an agent when it connects and the gateway
|
||||
// has something to make pictures or 3D models with.
|
||||
const mcpMediaInstructions = "Pictures, 3D models and a model that looks at pictures, on this gateway's own GPUs. " +
|
||||
"A typical loop: generate_image (style concept) -> generate_mesh from its file_id -> import the GLB -> render -> " +
|
||||
"review_images against the concept -> fix. Results are files: fetch them from their url with your API key. " +
|
||||
"AI meshes suit rocks, debris and background props; hard-surface hero assets are usually better built by hand " +
|
||||
"from the concept. A GPU holds one of these models at a time and switching costs a cold start, so batch calls " +
|
||||
"of the same kind; gateway_status says which is loaded."
|
||||
|
||||
// mediaModels are the gateway's models by what they do, by name, deduplicated
|
||||
// from the aliases that also resolve to them.
|
||||
type mediaModels struct{ images, meshes, vision, workflows []string }
|
||||
|
||||
func (r *Router) mediaModels() mediaModels {
|
||||
var out mediaModels
|
||||
seen := map[string]bool{}
|
||||
for _, m := range r.modelMap() {
|
||||
if seen[m.Name] || m.IsGuard() {
|
||||
continue
|
||||
}
|
||||
seen[m.Name] = true
|
||||
if m.MakesImages() {
|
||||
out.images = append(out.images, m.Name)
|
||||
}
|
||||
if m.MakesMeshes() {
|
||||
out.meshes = append(out.meshes, m.Name)
|
||||
}
|
||||
if m.Capabilities.Vision {
|
||||
out.vision = append(out.vision, m.Name)
|
||||
}
|
||||
if len(m.ComfyInputs()) > 0 {
|
||||
out.workflows = append(out.workflows, m.Name)
|
||||
}
|
||||
}
|
||||
for _, l := range [][]string{out.images, out.meshes, out.vision, out.workflows} {
|
||||
slices.Sort(l)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (mm mediaModels) any() bool {
|
||||
return len(mm.images)+len(mm.meshes)+len(mm.vision)+len(mm.workflows) > 0
|
||||
}
|
||||
|
||||
// defaultOf is the model a tool uses when the call names none: the one the
|
||||
// tool is best known with when the gateway has it, else the first.
|
||||
func defaultOf(models []string, preferred ...string) string {
|
||||
for _, p := range preferred {
|
||||
if slices.Contains(models, p) {
|
||||
return p
|
||||
}
|
||||
}
|
||||
if len(models) == 0 {
|
||||
return ""
|
||||
}
|
||||
return models[0]
|
||||
}
|
||||
|
||||
func enumOrNone(models []string) map[string]any {
|
||||
s := map[string]any{"type": "string"}
|
||||
if len(models) > 0 {
|
||||
s["enum"] = models
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// mediaTools are the tools for what the gateway's models can do: none for
|
||||
// what it has no model for.
|
||||
func (r *Router) mediaTools() []map[string]any {
|
||||
mm := r.mediaModels()
|
||||
var tools []map[string]any
|
||||
fileArg := map[string]any{"type": "string",
|
||||
"description": "A picture: a /v1/files id (file-…), a data URL, or base64"}
|
||||
wait := map[string]any{"type": "integer", "minimum": 0, "maximum": int(mcpWaitMax.Seconds()),
|
||||
"description": "Most seconds to wait for it before answering with the job to ask after (job_status)"}
|
||||
if len(mm.images) > 0 {
|
||||
tools = append(tools, map[string]any{
|
||||
"name": "generate_image",
|
||||
"description": "Make a picture from a prompt (~15 s warm, longer when the GPU must switch models). " +
|
||||
"Keeps the PNG as a file and answers its file_id, url and seed, with a small preview. " +
|
||||
"style: 'concept' (one object, 3/4 view, grey background: good for generate_mesh), 'side' " +
|
||||
"(exact side view: best meshes for vehicles), 'texture' (seamless tileable surface), 'none' (the prompt as is).",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"prompt"},
|
||||
"properties": map[string]any{
|
||||
"prompt": map[string]any{"type": "string"},
|
||||
"style": map[string]any{"type": "string", "enum": []string{"none", "concept", "side", "texture"}, "default": "none"},
|
||||
"size": map[string]any{"type": "integer", "description": "Square side in px, a multiple of 16, 256-2048", "default": 1024},
|
||||
"seed": map[string]any{"type": "integer"},
|
||||
"negative_prompt": map[string]any{"type": "string"},
|
||||
"model": enumOrNone(mm.images),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(mm.meshes) > 0 {
|
||||
tools = append(tools, map[string]any{
|
||||
"name": "generate_mesh",
|
||||
"description": "Make a 3D model (GLB) from a picture of one object on a plain background — the gateway " +
|
||||
"cuts the background out itself — or from a prompt, for which a concept picture is made first. " +
|
||||
"Takes 30 s to a few minutes; waits for it, saying how far along it is when the client takes a stream. " +
|
||||
"Answers the GLB's file_id and url, and its licence. The mesh is about 1 m across, its orientation arbitrary.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"image": fileArg,
|
||||
"prompt": map[string]any{"type": "string", "description": "Used when no image is given"},
|
||||
"model": enumOrNone(mm.meshes),
|
||||
"seed": map[string]any{"type": "integer"},
|
||||
"resolution": map[string]any{"type": "integer", "description": "The model's own grid, a multiple of 32 (TripoSR: 256 is enough)"},
|
||||
"texture_resolution": map[string]any{"type": "integer", "minimum": 512, "maximum": 2048,
|
||||
"description": "For a model that textures its mesh (sf3d): the texture's side in px"},
|
||||
"inputs": map[string]any{"type": "object",
|
||||
"description": "Any other input the model's workflow declares (gateway_status lists them): remesh, vertex_count, foreground_ratio, albedo_match…"},
|
||||
"wait_seconds": wait,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(mm.vision) > 0 {
|
||||
tools = append(tools, map[string]any{
|
||||
"name": "review_images",
|
||||
"description": "Ask a vision model about 1-4 pictures — a render beside its concept, say. A cheap critic " +
|
||||
"for render-and-fix loops. json_schema makes it answer strict JSON. A cold start can take minutes " +
|
||||
"when the GPU was making pictures.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"images", "question"},
|
||||
"properties": map[string]any{
|
||||
"images": map[string]any{"type": "array", "items": fileArg, "minItems": 1, "maxItems": 4},
|
||||
"question": map[string]any{"type": "string"},
|
||||
"json_schema": map[string]any{"type": "object", "description": "JSON Schema the answer must follow"},
|
||||
"model": enumOrNone(mm.vision),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if len(mm.workflows) > 0 {
|
||||
tools = append(tools, map[string]any{
|
||||
"name": "run_workflow",
|
||||
"description": "Run a ComfyUI workflow the gateway serves, with the inputs its catalog entry declares " +
|
||||
"(gateway_status lists them). A file input is a /v1/files id, a data URL or base64. Answers its result file.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"model", "inputs"},
|
||||
"properties": map[string]any{
|
||||
"model": enumOrNone(mm.workflows),
|
||||
"inputs": map[string]any{"type": "object"},
|
||||
"wait_seconds": wait,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
if mm.any() {
|
||||
tools = append(tools,
|
||||
map[string]any{
|
||||
"name": "job_status",
|
||||
"description": "Where a 3D model or workflow run stands, and its result file when done. wait_seconds waits for it to finish.",
|
||||
"inputSchema": map[string]any{
|
||||
"type": "object",
|
||||
"required": []string{"job_id"},
|
||||
"properties": map[string]any{"job_id": map[string]any{"type": "string"}, "wait_seconds": wait},
|
||||
},
|
||||
},
|
||||
map[string]any{
|
||||
"name": "gateway_status",
|
||||
"description": "The gateway's picture, 3D, vision and workflow models: whether each is loaded on a GPU now, " +
|
||||
"and a workflow's inputs; and your last few jobs. Use it to batch calls and avoid cold starts.",
|
||||
"inputSchema": map[string]any{"type": "object", "properties": map[string]any{}},
|
||||
})
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
// mediaCall is one tool call: who made it, and how to tell them how far it
|
||||
// has got.
|
||||
type mediaCall struct {
|
||||
req *http.Request
|
||||
progress func(percent float64, message string)
|
||||
// streamed is a call whose answer is a stream, which may wait longer.
|
||||
streamed bool
|
||||
}
|
||||
|
||||
func (c mediaCall) wait(asked *int) time.Duration {
|
||||
d := mcpWaitPlain
|
||||
if c.streamed {
|
||||
d = mcpWaitStreamed
|
||||
}
|
||||
if asked != nil {
|
||||
d = min(time.Duration(*asked)*time.Second, mcpWaitMax)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// isMediaTool is whether a tool is one of these.
|
||||
func isMediaTool(name string) bool {
|
||||
switch name {
|
||||
case "generate_image", "generate_mesh", "review_images", "run_workflow", "job_status", "gateway_status":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// callMediaTool runs one of the tools. As with the notes, a failure is a
|
||||
// tool result with isError set, which the model gets to read.
|
||||
func (r *Router) callMediaTool(c mediaCall, name string, args json.RawMessage) map[string]any {
|
||||
var res map[string]any
|
||||
var err error
|
||||
switch name {
|
||||
case "generate_image":
|
||||
res, err = r.toolGenerateImage(c, args)
|
||||
case "generate_mesh":
|
||||
res, err = r.toolGenerateMesh(c, args)
|
||||
case "review_images":
|
||||
res, err = r.toolReviewImages(c, args)
|
||||
case "run_workflow":
|
||||
res, err = r.toolRunWorkflow(c, args)
|
||||
case "job_status":
|
||||
res, err = r.toolJobStatus(c, args)
|
||||
case "gateway_status":
|
||||
res, err = r.toolGatewayStatus(c)
|
||||
default:
|
||||
err = errors.New("unknown tool " + name)
|
||||
}
|
||||
if err != nil {
|
||||
return toolText(err.Error(), true)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// self makes a request of the gateway's client API as the caller: their key,
|
||||
// their agent name, and the context the call was made in.
|
||||
func (r *Router) self(c mediaCall, method, path string, body any) (int, http.Header, []byte, error) {
|
||||
h := r.selfHandler.Load()
|
||||
if h == nil {
|
||||
return 0, nil, nil, errors.New("the gateway's API is not mounted here")
|
||||
}
|
||||
var rd *bytes.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
rd = bytes.NewReader(b)
|
||||
} else {
|
||||
rd = bytes.NewReader(nil)
|
||||
}
|
||||
out, err := http.NewRequestWithContext(c.req.Context(), method, path, rd)
|
||||
if err != nil {
|
||||
return 0, nil, nil, err
|
||||
}
|
||||
for _, k := range []string{"Authorization", "X-Api-Key", agentHeader, "X-Forwarded-For", "X-Real-Ip"} {
|
||||
if v := c.req.Header.Get(k); v != "" {
|
||||
out.Header.Set(k, v)
|
||||
}
|
||||
}
|
||||
out.RemoteAddr = c.req.RemoteAddr
|
||||
if body != nil {
|
||||
out.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
// A picture or a mesh comes back whole: past the 1 MiB a judge's
|
||||
// answer is held to.
|
||||
w := &bufferWriter{header: http.Header{}, max: mcpBody * 2}
|
||||
(*h).ServeHTTP(w, out)
|
||||
status := w.status
|
||||
if status == 0 {
|
||||
status = http.StatusOK
|
||||
}
|
||||
return status, w.header, w.buf.Bytes(), nil
|
||||
}
|
||||
|
||||
// selfJSON is self for an answer in JSON, a failure said as the API said it.
|
||||
func (r *Router) selfJSON(c mediaCall, method, path string, body, into any) (http.Header, error) {
|
||||
status, h, b, err := r.self(c, method, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if status >= 300 {
|
||||
return h, fmt.Errorf("%s %s: %d %s", method, path, status, errorMessage(b))
|
||||
}
|
||||
if into == nil {
|
||||
return h, nil
|
||||
}
|
||||
return h, json.Unmarshal(b, into)
|
||||
}
|
||||
|
||||
// selfJSONWaiting is selfJSON for a model that may be cold: an answer that
|
||||
// says to come back — a model loading, another holding the GPU, a full
|
||||
// queue, all with Retry-After — is waited out for as long as the call may
|
||||
// wait, saying so meanwhile. One that does not say when, a spent budget, is
|
||||
// not: waiting would not help.
|
||||
func (r *Router) selfJSONWaiting(c mediaCall, model, path string, body, into any) (http.Header, error) {
|
||||
deadline := time.Now().Add(c.wait(nil))
|
||||
for {
|
||||
status, h, b, err := r.self(c, http.MethodPost, path, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
retry, _ := strconv.Atoi(h.Get("Retry-After"))
|
||||
if status < 300 {
|
||||
return h, json.Unmarshal(b, into)
|
||||
}
|
||||
if retry <= 0 || (status != http.StatusTooManyRequests && status < 500) || !time.Now().Before(deadline) {
|
||||
msg := fmt.Sprintf("POST %s: %d %s", path, status, errorMessage(b))
|
||||
if retry > 0 {
|
||||
msg += "; still not ready after waiting — call again, or ask gateway_status which model holds the GPU"
|
||||
}
|
||||
return h, errors.New(msg)
|
||||
}
|
||||
say := model + " is loading"
|
||||
if held := h.Get(waitingForHeader); held != "" {
|
||||
say = model + " is waiting for " + held + " to give up the GPU"
|
||||
if eta := h.Get(etaHeader); eta != "" {
|
||||
say += ", about " + eta + " s"
|
||||
}
|
||||
}
|
||||
c.progress(5, say)
|
||||
select {
|
||||
case <-c.req.Context().Done():
|
||||
return nil, c.req.Context().Err()
|
||||
case <-time.After(min(time.Duration(retry)*time.Second, 10*time.Second, time.Until(deadline)+time.Second)):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fileURL is where a file is fetched from, with the caller's key, as the
|
||||
// caller reached the gateway.
|
||||
func fileURL(req *http.Request, id string) string {
|
||||
scheme := "http"
|
||||
if req.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
if p := req.Header.Get("X-Forwarded-Proto"); p != "" {
|
||||
scheme = strings.TrimSpace(strings.Split(p, ",")[0])
|
||||
}
|
||||
host := req.Host
|
||||
if h := req.Header.Get("X-Forwarded-Host"); h != "" {
|
||||
host = strings.TrimSpace(strings.Split(h, ",")[0])
|
||||
}
|
||||
return scheme + "://" + host + "/v1/files/" + id + "/content"
|
||||
}
|
||||
|
||||
// keepFile keeps what a tool made as the caller's file, for as long as a
|
||||
// job's result is kept.
|
||||
func (r *Router) keepFile(c mediaCall, name string, content []byte) (store.File, error) {
|
||||
p, ok := auth.FromContext(c.req.Context())
|
||||
if !ok || r.store == nil {
|
||||
return store.File{}, errors.New("this gateway keeps no files (it runs without authentication or a store)")
|
||||
}
|
||||
if r.noStore(callerSubject(c.req)) {
|
||||
return store.File{}, errors.New("the gateway's policy for this caller keeps nothing, so no file is kept")
|
||||
}
|
||||
_, _, _, days := r.batchCfg()
|
||||
now := time.Now()
|
||||
f := store.File{ID: newID("file-"), Owner: ownerOf(p), Purpose: purposeJobOutput, Filename: name,
|
||||
Bytes: int64(len(content)), CreatedAt: now, ExpiresAt: now.AddDate(0, 0, days)}
|
||||
return f, r.store.PutFile(c.req.Context(), f, content)
|
||||
}
|
||||
|
||||
// picture reads a picture argument: a file of the caller's by id, a data URL,
|
||||
// or base64.
|
||||
func (r *Router) picture(c mediaCall, s string) ([]byte, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
switch {
|
||||
case s == "":
|
||||
return nil, errors.New("no picture given")
|
||||
case strings.HasPrefix(s, "file-"):
|
||||
p, ok := auth.FromContext(c.req.Context())
|
||||
if !ok || r.store == nil {
|
||||
return nil, errors.New("this gateway keeps no files: send the picture as base64 or a data URL")
|
||||
}
|
||||
f, err := r.store.FileByID(c.req.Context(), s)
|
||||
if errors.Is(err, store.ErrNotFound) || err == nil && f.Owner != ownerOf(p) {
|
||||
return nil, fmt.Errorf("no file %s of yours", s)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r.store.FileContent(c.req.Context(), s)
|
||||
case strings.HasPrefix(s, "data:"):
|
||||
_, after, ok := strings.Cut(s, ";base64,")
|
||||
if !ok {
|
||||
return nil, errors.New("a data URL without base64 in it")
|
||||
}
|
||||
s = after
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(s)
|
||||
if err != nil {
|
||||
return nil, errors.New("the picture is neither a file id, a data URL nor base64")
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
func dataURL(png []byte) string {
|
||||
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png)
|
||||
}
|
||||
|
||||
// shrink returns a picture as a PNG whose long side is at most edge.
|
||||
func shrink(pic []byte, edge int) ([]byte, error) {
|
||||
src, format, err := image.Decode(bytes.NewReader(pic))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("not a picture this gateway reads (PNG or JPEG): %w", err)
|
||||
}
|
||||
b := src.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
if max(w, h) <= edge {
|
||||
if format == "png" {
|
||||
return pic, nil
|
||||
}
|
||||
edge = max(w, h) // only re-encoded
|
||||
}
|
||||
scale := float64(edge) / float64(max(w, h))
|
||||
dw, dh := max(1, int(float64(w)*scale+0.5)), max(1, int(float64(h)*scale+0.5))
|
||||
dst := image.NewNRGBA(image.Rect(0, 0, dw, dh))
|
||||
// Each pixel the average of the box of pixels it stands for.
|
||||
for y := range dh {
|
||||
y0, y1 := b.Min.Y+y*h/dh, b.Min.Y+max((y+1)*h/dh, y*h/dh+1)
|
||||
for x := range dw {
|
||||
x0, x1 := b.Min.X+x*w/dw, b.Min.X+max((x+1)*w/dw, x*w/dw+1)
|
||||
var sr, sg, sb, sa, n uint64
|
||||
for yy := y0; yy < y1; yy++ {
|
||||
for xx := x0; xx < x1; xx++ {
|
||||
cr, cg, cb, ca := src.At(xx, yy).RGBA()
|
||||
sr, sg, sb, sa, n = sr+uint64(cr), sg+uint64(cg), sb+uint64(cb), sa+uint64(ca), n+1
|
||||
}
|
||||
}
|
||||
// Premultiplied sums, back to straight colour.
|
||||
if sa == 0 {
|
||||
continue
|
||||
}
|
||||
dst.SetNRGBA(x, y, color.NRGBA{
|
||||
R: uint8(sr * 0xff / sa), G: uint8(sg * 0xff / sa), B: uint8(sb * 0xff / sa), A: uint8(sa / n >> 8),
|
||||
})
|
||||
}
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := png.Encode(&out, dst); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// --- the tools ---
|
||||
|
||||
func (r *Router) toolGenerateImage(c mediaCall, raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Style string `json:"style"`
|
||||
NegativePrompt string `json:"negative_prompt"`
|
||||
Model string `json:"model"`
|
||||
Size int `json:"size"`
|
||||
Seed *int64 `json:"seed"`
|
||||
}
|
||||
if err := decodeArgs(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, _, err := r.makeImage(c, a.Prompt, a.Style, a.NegativePrompt, a.Model, a.Size, a.Seed)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// makeImage makes a picture and keeps it: the tool's answer, and the PNG.
|
||||
func (r *Router) makeImage(c mediaCall, prompt, style, negative, model string, size int, seed *int64) (map[string]any, []byte, error) {
|
||||
if strings.TrimSpace(prompt) == "" {
|
||||
return nil, nil, errors.New("prompt is required")
|
||||
}
|
||||
if style == "" {
|
||||
style = "none"
|
||||
}
|
||||
suffix, ok := imageStyles[style]
|
||||
if !ok {
|
||||
return nil, nil, fmt.Errorf("style %q: concept, side, texture or none", style)
|
||||
}
|
||||
if model == "" {
|
||||
model = defaultOf(r.mediaModels().images, "flux-schnell")
|
||||
}
|
||||
if size == 0 {
|
||||
size = 1024
|
||||
}
|
||||
body := map[string]any{"model": model, "prompt": prompt + suffix, "n": 1,
|
||||
"size": fmt.Sprintf("%dx%d", size, size), "response_format": "b64_json"}
|
||||
if seed != nil {
|
||||
body["seed"] = *seed
|
||||
}
|
||||
if negative != "" {
|
||||
body["negative_prompt"] = negative
|
||||
}
|
||||
c.progress(5, "making the picture with "+model)
|
||||
var out struct {
|
||||
Data []struct {
|
||||
B64 string `json:"b64_json"`
|
||||
} `json:"data"`
|
||||
}
|
||||
h, err := r.selfJSONWaiting(c, model, imagesPath, body, &out)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(out.Data) == 0 {
|
||||
return nil, nil, errors.New(model + " made no picture")
|
||||
}
|
||||
pic, err := base64.StdEncoding.DecodeString(out.Data[0].B64)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
info := map[string]any{"model": model, "width": size, "height": size}
|
||||
if s, err := strconv.ParseInt(h.Get("x-llmgw-seed"), 10, 64); err == nil {
|
||||
info["seed"] = s
|
||||
}
|
||||
if m, ok := r.modelMap()[model]; ok && m.License != nil {
|
||||
info["license"] = m.License
|
||||
}
|
||||
content := []map[string]any{}
|
||||
if f, err := r.keepFile(c, "image-"+strings.TrimPrefix(newID(""), "-")+".png", pic); err == nil {
|
||||
info["file_id"], info["url"], info["bytes"] = f.ID, fileURL(c.req, f.ID), f.Bytes
|
||||
} else {
|
||||
// Nowhere to keep it: the picture itself, whole.
|
||||
info["note"] = err.Error() + "; the picture is in this answer at full size"
|
||||
content = append(content, map[string]any{"type": "image", "mimeType": "image/png", "data": out.Data[0].B64})
|
||||
}
|
||||
text, _ := json.Marshal(info)
|
||||
content = append([]map[string]any{{"type": "text", "text": string(text)}}, content...)
|
||||
if _, kept := info["file_id"]; kept {
|
||||
if small, err := shrink(pic, previewEdge); err == nil {
|
||||
content = append(content, map[string]any{"type": "image", "mimeType": "image/png",
|
||||
"data": base64.StdEncoding.EncodeToString(small)})
|
||||
}
|
||||
}
|
||||
return map[string]any{"content": content, "isError": false}, pic, nil
|
||||
}
|
||||
|
||||
func (r *Router) toolGenerateMesh(c mediaCall, raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Image, Prompt, Model string
|
||||
Seed *int64
|
||||
Resolution int
|
||||
TextureResolution int `json:"texture_resolution"`
|
||||
Inputs map[string]any
|
||||
WaitSeconds *int `json:"wait_seconds"`
|
||||
}
|
||||
if err := decodeArgs(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mm := r.mediaModels()
|
||||
if a.Model == "" {
|
||||
a.Model = defaultOf(mm.meshes, "sf3d", "triposr")
|
||||
}
|
||||
m, ok := r.modelMap()[a.Model]
|
||||
if !ok || !m.MakesMeshes() {
|
||||
return nil, fmt.Errorf("%q makes no 3D models here; these do: %s", a.Model, strings.Join(mm.meshes, ", "))
|
||||
}
|
||||
extra := map[string]any{}
|
||||
var pic []byte
|
||||
var err error
|
||||
switch {
|
||||
case a.Image != "":
|
||||
if pic, err = r.picture(c, a.Image); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
case a.Prompt != "":
|
||||
res, made, err := r.makeImage(c, a.Prompt, "concept", "", "", 0, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("the concept picture: %w", err)
|
||||
}
|
||||
pic = made
|
||||
var info map[string]any
|
||||
if json.Unmarshal([]byte(res["content"].([]map[string]any)[0]["text"].(string)), &info) == nil {
|
||||
extra["concept_file_id"], extra["concept_seed"] = info["file_id"], info["seed"]
|
||||
}
|
||||
default:
|
||||
return nil, errors.New("an image, or a prompt to make one from")
|
||||
}
|
||||
|
||||
// A model whose workflow declares its inputs takes them at the
|
||||
// workflows' path, every one it names; one that does not, the 3D path's
|
||||
// seed and grid.
|
||||
path, body := meshesPath, map[string]any{"model": a.Model, "image": dataURL(pic)}
|
||||
if declared := m.ComfyInputs(); len(declared) > 0 {
|
||||
names := map[string]string{}
|
||||
var image string
|
||||
for _, in := range declared {
|
||||
names[in.Name] = in.Type
|
||||
if image == "" && in.Type == "image" {
|
||||
image = in.Name
|
||||
}
|
||||
}
|
||||
if image == "" {
|
||||
return nil, fmt.Errorf("%s's workflow takes no picture", a.Model)
|
||||
}
|
||||
inputs := map[string]any{image: dataURL(pic)}
|
||||
// A file of the caller's goes by its id, which the API takes.
|
||||
if id := strings.TrimSpace(a.Image); strings.HasPrefix(id, "file-") {
|
||||
inputs[image] = id
|
||||
}
|
||||
for k, v := range a.Inputs {
|
||||
inputs[k] = v
|
||||
}
|
||||
|
||||
// A textured mesh's resolution is its texture's; a grid's, the grid's.
|
||||
if _, ok := names["resolution"]; ok {
|
||||
switch {
|
||||
case a.TextureResolution > 0:
|
||||
inputs["resolution"] = a.TextureResolution
|
||||
case a.Resolution > 0:
|
||||
inputs["resolution"] = a.Resolution
|
||||
}
|
||||
}
|
||||
path, body = workflowsPath, map[string]any{"model": a.Model, "inputs": inputs}
|
||||
if a.Seed != nil {
|
||||
body["seed"] = *a.Seed
|
||||
}
|
||||
} else {
|
||||
if a.Seed != nil {
|
||||
body["seed"] = *a.Seed
|
||||
}
|
||||
if a.Resolution > 0 {
|
||||
body["resolution"] = a.Resolution
|
||||
}
|
||||
}
|
||||
var job struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if _, err := r.selfJSON(c, http.MethodPost, path, body, &job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.progress(10, "queued as "+job.ID)
|
||||
return r.waitJob(c, job.ID, c.wait(a.WaitSeconds), extra)
|
||||
}
|
||||
|
||||
func (r *Router) toolRunWorkflow(c mediaCall, raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Model string
|
||||
Inputs map[string]any
|
||||
WaitSeconds *int `json:"wait_seconds"`
|
||||
}
|
||||
if err := decodeArgs(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := r.modelMap()[a.Model]
|
||||
if !ok || len(m.ComfyInputs()) == 0 {
|
||||
return nil, fmt.Errorf("%q runs no workflow here; these do: %s", a.Model, strings.Join(r.mediaModels().workflows, ", "))
|
||||
}
|
||||
// A file input given as a file id goes as it is: the API takes those.
|
||||
var job struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
if _, err := r.selfJSON(c, http.MethodPost, workflowsPath, map[string]any{"model": a.Model, "inputs": a.Inputs}, &job); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.progress(10, "queued as "+job.ID)
|
||||
return r.waitJob(c, job.ID, c.wait(a.WaitSeconds), nil)
|
||||
}
|
||||
|
||||
func (r *Router) toolJobStatus(c mediaCall, raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
JobID string `json:"job_id"`
|
||||
WaitSeconds *int `json:"wait_seconds"`
|
||||
}
|
||||
if err := decodeArgs(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.JobID == "" {
|
||||
return nil, errors.New("job_id is required")
|
||||
}
|
||||
d := time.Duration(0)
|
||||
if a.WaitSeconds != nil {
|
||||
d = c.wait(a.WaitSeconds)
|
||||
}
|
||||
return r.waitJob(c, a.JobID, d, nil)
|
||||
}
|
||||
|
||||
// jobView is the part of a job a tool says.
|
||||
type jobView struct {
|
||||
ID string `json:"id"`
|
||||
Model string `json:"model"`
|
||||
Status string `json:"status"`
|
||||
Progress json.RawMessage `json:"progress"`
|
||||
OutputFileID *string `json:"output_file_id"`
|
||||
ContentType *string `json:"content_type"`
|
||||
Error *struct {
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
License json.RawMessage `json:"license"`
|
||||
}
|
||||
|
||||
// waitJob asks after a job until it is done or d has passed, saying how far
|
||||
// along it is, and answers where it stands: its file when done.
|
||||
func (r *Router) waitJob(c mediaCall, id string, d time.Duration, extra map[string]any) (map[string]any, error) {
|
||||
deadline := time.Now().Add(d)
|
||||
var j jobView
|
||||
for {
|
||||
if _, err := r.selfJSON(c, http.MethodGet, workflowsPath+"/"+id, nil, &j); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if store.JobDone(j.Status) || !time.Now().Before(deadline) {
|
||||
break
|
||||
}
|
||||
pct, msg := jobProgress(j)
|
||||
c.progress(pct, msg)
|
||||
select {
|
||||
case <-c.req.Context().Done():
|
||||
return nil, c.req.Context().Err()
|
||||
case <-time.After(mcpPoll):
|
||||
}
|
||||
}
|
||||
info := map[string]any{"job_id": j.ID, "model": j.Model, "status": j.Status}
|
||||
for k, v := range extra {
|
||||
if v != nil {
|
||||
info[k] = v
|
||||
}
|
||||
}
|
||||
if len(j.License) > 0 && string(j.License) != "null" {
|
||||
info["license"] = j.License
|
||||
}
|
||||
switch {
|
||||
case j.Status == store.JobCompleted && j.OutputFileID != nil:
|
||||
info["file_id"], info["url"] = *j.OutputFileID, fileURL(c.req, *j.OutputFileID)
|
||||
if j.ContentType != nil {
|
||||
info["content_type"] = *j.ContentType
|
||||
}
|
||||
if r.store != nil {
|
||||
if f, err := r.store.FileByID(c.req.Context(), *j.OutputFileID); err == nil {
|
||||
info["bytes"] = f.Bytes
|
||||
}
|
||||
}
|
||||
case store.JobDone(j.Status):
|
||||
if j.Error != nil {
|
||||
info["error"] = j.Error.Message
|
||||
}
|
||||
b, _ := json.Marshal(info)
|
||||
return toolText(string(b), true), nil
|
||||
default:
|
||||
info["progress"] = j.Progress
|
||||
info["next"] = "not done yet: call job_status with this job_id (and wait_seconds) to wait for it"
|
||||
}
|
||||
b, _ := json.Marshal(info)
|
||||
return toolText(string(b), false), nil
|
||||
}
|
||||
|
||||
// jobProgress reads a job's progress as a percent and a phrase.
|
||||
func jobProgress(j jobView) (float64, string) {
|
||||
var p struct {
|
||||
State string `json:"state"`
|
||||
Ahead int `json:"ahead"`
|
||||
Percent float64 `json:"percent"`
|
||||
Node string `json:"node_class"`
|
||||
HeldBy string `json:"held_by"`
|
||||
}
|
||||
_ = json.Unmarshal(j.Progress, &p)
|
||||
switch {
|
||||
case p.State == "queued":
|
||||
return 10, fmt.Sprintf("%s: queued, %d ahead", j.ID, p.Ahead)
|
||||
case p.State == "waiting_gpu":
|
||||
return 10, j.ID + ": waiting for the GPU, held by " + p.HeldBy
|
||||
case p.State == "running":
|
||||
return 10 + 0.9*p.Percent, fmt.Sprintf("%s: %s %.0f%%", j.ID, p.Node, p.Percent)
|
||||
}
|
||||
return 10, j.ID + ": " + j.Status
|
||||
}
|
||||
|
||||
func (r *Router) toolReviewImages(c mediaCall, raw json.RawMessage) (map[string]any, error) {
|
||||
var a struct {
|
||||
Images []string
|
||||
Question string
|
||||
JSONSchema map[string]any `json:"json_schema"`
|
||||
Model string
|
||||
}
|
||||
if err := decodeArgs(raw, &a); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(a.Images) == 0 || len(a.Images) > 4 {
|
||||
return nil, errors.New("one to four images")
|
||||
}
|
||||
if strings.TrimSpace(a.Question) == "" {
|
||||
return nil, errors.New("question is required")
|
||||
}
|
||||
if a.Model == "" {
|
||||
a.Model = defaultOf(r.mediaModels().vision, "vlm")
|
||||
}
|
||||
if a.Model == "" {
|
||||
return nil, errors.New("no model here looks at pictures")
|
||||
}
|
||||
parts := []map[string]any{{"type": "text", "text": a.Question}}
|
||||
for i, s := range a.Images {
|
||||
pic, err := r.picture(c, s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("image %d: %w", i+1, err)
|
||||
}
|
||||
small, err := shrink(pic, lookEdge)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("image %d: %w", i+1, err)
|
||||
}
|
||||
parts = append(parts, map[string]any{"type": "image_url", "image_url": map[string]any{"url": dataURL(small)}})
|
||||
}
|
||||
body := map[string]any{"model": a.Model, "messages": []map[string]any{{"role": "user", "content": parts}}}
|
||||
if a.JSONSchema != nil {
|
||||
body["response_format"] = map[string]any{"type": "json_schema",
|
||||
"json_schema": map[string]any{"name": "answer", "schema": a.JSONSchema, "strict": true}}
|
||||
}
|
||||
c.progress(10, "asking "+a.Model)
|
||||
var out struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
if _, err := r.selfJSONWaiting(c, a.Model, "/v1/chat/completions", body, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(out.Choices) == 0 {
|
||||
return nil, errors.New(a.Model + " gave no answer")
|
||||
}
|
||||
return toolText(out.Choices[0].Message.Content, false), nil
|
||||
}
|
||||
|
||||
func (r *Router) toolGatewayStatus(c mediaCall) (map[string]any, error) {
|
||||
var models struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if _, err := r.selfJSON(c, http.MethodGet, "/v1/models", nil, &models); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var listed []map[string]any
|
||||
for _, m := range models.Data {
|
||||
caps, _ := m["capabilities"].(map[string]any)
|
||||
if caps["images"] != true && caps["meshes"] != true && caps["vision"] != true && m["workflow"] == nil {
|
||||
continue
|
||||
}
|
||||
e := map[string]any{"id": m["id"], "loaded": m["loaded"], "capabilities": caps}
|
||||
if w := m["workflow"]; w != nil {
|
||||
e["workflow"] = w
|
||||
}
|
||||
if l := m["license"]; l != nil {
|
||||
e["license"] = l
|
||||
}
|
||||
listed = append(listed, e)
|
||||
}
|
||||
out := map[string]any{"models": listed}
|
||||
var jobs struct {
|
||||
Data []jobView `json:"data"`
|
||||
}
|
||||
if _, err := r.selfJSON(c, http.MethodGet, workflowsPath+"?limit=5", nil, &jobs); err == nil {
|
||||
recent := make([]map[string]any, 0, len(jobs.Data))
|
||||
for _, j := range jobs.Data {
|
||||
recent = append(recent, map[string]any{"id": j.ID, "model": j.Model, "status": j.Status})
|
||||
}
|
||||
out["recent_jobs"] = recent
|
||||
}
|
||||
return toolJSON(out), nil
|
||||
}
|
||||
|
||||
// decodeArgs reads a tool's arguments, by their JSON names: a field's own
|
||||
// name in lower case unless its tag says.
|
||||
func decodeArgs(raw json.RawMessage, into any) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
raw = []byte("{}")
|
||||
}
|
||||
if err := json.Unmarshal(raw, into); err != nil {
|
||||
return fmt.Errorf("the arguments: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// streamWriter answers a tool call as server-sent events: progress
|
||||
// notifications while it runs, then the answer.
|
||||
type streamWriter struct {
|
||||
mu sync.Mutex
|
||||
w http.ResponseWriter
|
||||
token json.RawMessage
|
||||
}
|
||||
|
||||
func (s *streamWriter) write(text string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, _ = fmt.Fprint(s.w, text)
|
||||
if f, ok := s.w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *streamWriter) event(v any) {
|
||||
b, _ := json.Marshal(v)
|
||||
s.write("event: message\ndata: " + string(b) + "\n\n")
|
||||
}
|
||||
|
||||
// keepAlive writes a comment every while until stop is closed: a picture
|
||||
// whose model starts cold is minutes of nothing said, which a proxy reads as
|
||||
// a connection gone idle.
|
||||
func (s *streamWriter) keepAlive(stop <-chan struct{}, every time.Duration) {
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case <-t.C:
|
||||
s.write(": still working\n\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *streamWriter) progress(percent float64, message string) {
|
||||
if len(s.token) == 0 {
|
||||
return
|
||||
}
|
||||
s.event(map[string]any{"jsonrpc": "2.0", "method": "notifications/progress", "params": map[string]any{
|
||||
"progressToken": s.token, "progress": percent, "total": 100, "message": message}})
|
||||
}
|
||||
|
||||
// acceptsStream is a client that takes a POST's answer as a stream, as the
|
||||
// streamable HTTP transport lets it.
|
||||
func acceptsStream(req *http.Request) bool {
|
||||
return strings.Contains(req.Header.Get("Accept"), "text/event-stream")
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/config"
|
||||
"gitea.mynodes.xyz/ilker/llmgw/internal/control/catalog"
|
||||
)
|
||||
|
||||
// mcp makes one JSON-RPC call at /mcp and answers its result; streamed, as a
|
||||
// client that takes server-sent events, it answers the events' data too.
|
||||
func mcp(t *testing.T, h http.Handler, secret, method string, params any, streamed bool) (map[string]any, []string) {
|
||||
t.Helper()
|
||||
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params})
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(b))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if secret != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
}
|
||||
if streamed {
|
||||
req.Header.Set("Accept", "application/json, text/event-stream")
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("%s: %d %s", method, rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
var events []string
|
||||
if streamed {
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "text/event-stream" {
|
||||
t.Fatalf("a streamed call answered %s: %s", ct, body)
|
||||
}
|
||||
for _, line := range strings.Split(body, "\n") {
|
||||
if d, ok := strings.CutPrefix(line, "data: "); ok {
|
||||
events = append(events, d)
|
||||
}
|
||||
}
|
||||
if len(events) == 0 {
|
||||
t.Fatalf("no events: %s", body)
|
||||
}
|
||||
body = events[len(events)-1]
|
||||
}
|
||||
var resp struct {
|
||||
Result map[string]any `json:"result"`
|
||||
Error any `json:"error"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(body), &resp); err != nil || resp.Error != nil {
|
||||
t.Fatalf("%s: %v %s", method, err, body)
|
||||
}
|
||||
return resp.Result, events
|
||||
}
|
||||
|
||||
func toolNames(res map[string]any) []string {
|
||||
var names []string
|
||||
for _, tl := range res["tools"].([]any) {
|
||||
names = append(names, tl.(map[string]any)["name"].(string))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// resultInfo is a tool result's first text, as the JSON it holds.
|
||||
func resultInfo(t *testing.T, res map[string]any) map[string]any {
|
||||
t.Helper()
|
||||
if res["isError"] == true {
|
||||
t.Fatalf("the tool failed: %v", res["content"])
|
||||
}
|
||||
var info map[string]any
|
||||
text := res["content"].([]any)[0].(map[string]any)["text"].(string)
|
||||
if err := json.Unmarshal([]byte(text), &info); err != nil {
|
||||
t.Fatalf("not JSON: %s", text)
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
func TestTheMCPToolsAreWhatTheGatewaysModelsCanDo(t *testing.T) {
|
||||
c := newMeshComfy(t)
|
||||
rt, secret := meshRouter(t, c)
|
||||
h := rt.Handler()
|
||||
init, _ := mcp(t, h, secret, "initialize", map[string]any{"protocolVersion": "2025-06-18"}, false)
|
||||
if init["protocolVersion"] != "2025-06-18" || !strings.Contains(init["instructions"].(string), "batch calls") {
|
||||
t.Errorf("initialize: %v", init)
|
||||
}
|
||||
list, _ := mcp(t, h, secret, "tools/list", map[string]any{}, false)
|
||||
names := strings.Join(toolNames(list), ",")
|
||||
for _, want := range []string{"remember", "generate_image", "generate_mesh", "job_status", "gateway_status"} {
|
||||
if !strings.Contains(names, want) {
|
||||
t.Errorf("no %s in %s", want, names)
|
||||
}
|
||||
}
|
||||
// Nothing here looks at pictures or declares a workflow's inputs.
|
||||
if strings.Contains(names, "review_images") || strings.Contains(names, "run_workflow") {
|
||||
t.Errorf("tools for models the gateway has not: %s", names)
|
||||
}
|
||||
|
||||
// A gateway with only text models offers the notes alone.
|
||||
plain := newTestRouter(t, baseConfig()).Handler()
|
||||
list, _ = mcp(t, plain, "", "tools/list", map[string]any{}, false)
|
||||
if got := strings.Join(toolNames(list), ","); got != "remember,recall,forget" {
|
||||
t.Errorf("a text-only gateway offers %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMeshIsMadeThroughMCPAndKeptAsTheCallersFile(t *testing.T) {
|
||||
c := newMeshComfy(t)
|
||||
rt, secret := meshRouter(t, c)
|
||||
h := rt.Handler()
|
||||
args := map[string]any{"model": "trellis2", "image": "data:image/png;base64," + testPicture(t), "seed": 3}
|
||||
|
||||
for _, streamed := range []bool{false, true} {
|
||||
res, events := mcp(t, h, secret, "tools/call", map[string]any{"name": "generate_mesh", "arguments": args,
|
||||
"_meta": map[string]any{"progressToken": "tok"}}, streamed)
|
||||
info := resultInfo(t, res)
|
||||
if info["status"] != "completed" || info["license"].(map[string]any)["name"] != "MIT" {
|
||||
t.Fatalf("streamed %v: %v", streamed, info)
|
||||
}
|
||||
id, _ := info["file_id"].(string)
|
||||
if !strings.HasSuffix(info["url"].(string), "/v1/files/"+id+"/content") {
|
||||
t.Errorf("url %v", info["url"])
|
||||
}
|
||||
// Fetched with the same key, it is the mesh.
|
||||
code, _, file := call(t, h, secret, http.MethodGet, "/v1/files/"+id+"/content", nil)
|
||||
if code != http.StatusOK || !strings.HasPrefix(file, "glTF") {
|
||||
t.Fatalf("the file is not the mesh: %d", code)
|
||||
}
|
||||
if streamed && !strings.Contains(strings.Join(events, "\n"), `"progressToken":"tok"`) {
|
||||
t.Errorf("no progress said while it ran: %v", events)
|
||||
}
|
||||
}
|
||||
// The job went through the client API as the caller: it is theirs.
|
||||
_, list, _ := call(t, h, secret, http.MethodGet, meshesPath, nil)
|
||||
if n := len(list["data"].([]any)); n != 2 {
|
||||
t.Errorf("%d jobs of the caller's, want the two made", n)
|
||||
}
|
||||
// Under no key, nothing is made.
|
||||
b, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": 1, "method": "tools/call",
|
||||
"params": map[string]any{"name": "generate_mesh", "arguments": args}})
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", bytes.NewReader(b))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Errorf("a call with no key: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPictureMadeThroughMCPComesWholeWhereNoFileCanBeKept(t *testing.T) {
|
||||
rt, queued, mu := imageRouter(t)
|
||||
h := rt.Handler()
|
||||
res, _ := mcp(t, h, "", "tools/call", map[string]any{"name": "generate_image",
|
||||
"arguments": map[string]any{"prompt": "a rock", "style": "concept", "size": 512, "seed": 9}}, false)
|
||||
info := resultInfo(t, res)
|
||||
if info["seed"] != 9.0 || info["model"] != "flux" || !strings.Contains(info["note"].(string), "keeps no files") {
|
||||
t.Errorf("info %v", info)
|
||||
}
|
||||
content := res["content"].([]any)
|
||||
if len(content) != 2 || content[1].(map[string]any)["type"] != "image" {
|
||||
t.Fatalf("the picture is not in the answer: %v", content)
|
||||
}
|
||||
if b, _ := base64.StdEncoding.DecodeString(content[1].(map[string]any)["data"].(string)); string(b) != "png-bytes" {
|
||||
t.Errorf("picture %q", b)
|
||||
}
|
||||
mu.Lock()
|
||||
wf, _ := json.Marshal(*queued)
|
||||
mu.Unlock()
|
||||
if !strings.Contains(string(wf), "a rock, single object centered") || !strings.Contains(string(wf), `"width":512`) {
|
||||
t.Errorf("the style was not added to the prompt: %s", wf)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPictureIsShrunkToItsLongSide(t *testing.T) {
|
||||
var b bytes.Buffer
|
||||
_ = png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 2000, 1000)))
|
||||
out, err := shrink(b.Bytes(), 512)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
im, err := png.Decode(bytes.NewReader(out))
|
||||
if err != nil || im.Bounds().Dx() != 512 || im.Bounds().Dy() != 256 {
|
||||
t.Fatalf("%v %v", im.Bounds(), err)
|
||||
}
|
||||
b.Reset()
|
||||
_ = png.Encode(&b, image.NewRGBA(image.Rect(0, 0, 100, 50)))
|
||||
if out, _ := shrink(b.Bytes(), 512); !bytes.Equal(out, b.Bytes()) {
|
||||
t.Error("a small PNG was re-encoded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAWorkflowRunsThroughMCPFromItsDeclaredInputs(t *testing.T) {
|
||||
wav := testWAV()
|
||||
c := newRunComfy(t, wav, "song_00001_.wav")
|
||||
rt, secret, _ := runRouter(t, c, songWorkflow, catalog.OutputAudio)
|
||||
h := rt.Handler()
|
||||
list, _ := mcp(t, h, secret, "tools/list", map[string]any{}, false)
|
||||
if !strings.Contains(strings.Join(toolNames(list), ","), "run_workflow") {
|
||||
t.Fatalf("no run_workflow for a model that declares its inputs: %v", toolNames(list))
|
||||
}
|
||||
res, _ := mcp(t, h, secret, "tools/call", map[string]any{"name": "run_workflow", "arguments": map[string]any{
|
||||
"model": "ace", "inputs": map[string]any{"tags": "synthwave", "cover": "data:image/png;base64," + testPicture(t)}}}, false)
|
||||
info := resultInfo(t, res)
|
||||
if info["status"] != "completed" || info["content_type"] != "audio/wav" {
|
||||
t.Fatalf("%v", info)
|
||||
}
|
||||
if _, _, file := call(t, h, secret, http.MethodGet, "/v1/files/"+info["file_id"].(string)+"/content", nil); file != string(wav) {
|
||||
t.Error("the file is not the song")
|
||||
}
|
||||
// A run refused says why, as the API did.
|
||||
res, _ = mcp(t, h, secret, "tools/call", map[string]any{"name": "run_workflow", "arguments": map[string]any{
|
||||
"model": "ace", "inputs": map[string]any{"cover": "data:image/png;base64," + testPicture(t)}}}, false)
|
||||
if res["isError"] != true || !strings.Contains(fmt.Sprint(res["content"]), "tags") {
|
||||
t.Errorf("a run missing a required input: %v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPicturesAreLookedAtByAVisionModelShrunkAndToASchema(t *testing.T) {
|
||||
e := newFakeEngine(t, "vlm")
|
||||
cfg := baseConfig(config.Endpoint{ID: "a", URL: e.srv.URL, Models: []string{"vlm"}})
|
||||
vlm := catModel("vlm", nil)
|
||||
vlm.Capabilities.Vision = true
|
||||
cfg.Models = []catalog.Model{vlm}
|
||||
h := newTestRouter(t, cfg).Handler()
|
||||
|
||||
var big bytes.Buffer
|
||||
_ = png.Encode(&big, image.NewRGBA(image.Rect(0, 0, 3000, 1500)))
|
||||
res, _ := mcp(t, h, "", "tools/call", map[string]any{"name": "review_images", "arguments": map[string]any{
|
||||
"images": []string{base64.StdEncoding.EncodeToString(big.Bytes())},
|
||||
"question": "Does the render match the concept?",
|
||||
"json_schema": map[string]any{"type": "object", "properties": map[string]any{"match": map[string]any{"type": "boolean"}}},
|
||||
}}, false)
|
||||
if res["isError"] == true || !strings.Contains(fmt.Sprint(res["content"]), "hi") {
|
||||
t.Fatalf("%v", res)
|
||||
}
|
||||
var sent struct {
|
||||
ResponseFormat struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"response_format"`
|
||||
Messages []struct {
|
||||
Content []struct {
|
||||
Type string `json:"type"`
|
||||
ImageURL struct {
|
||||
URL string `json:"url"`
|
||||
} `json:"image_url"`
|
||||
} `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(e.last.Load().([]byte), &sent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent.ResponseFormat.Type != "json_schema" || len(sent.Messages) != 1 || len(sent.Messages[0].Content) != 2 {
|
||||
t.Fatalf("sent %+v", sent)
|
||||
}
|
||||
b64, _ := strings.CutPrefix(sent.Messages[0].Content[1].ImageURL.URL, "data:image/png;base64,")
|
||||
pic, _ := base64.StdEncoding.DecodeString(b64)
|
||||
im, err := png.Decode(bytes.NewReader(pic))
|
||||
if err != nil || im.Bounds().Dx() != lookEdge || im.Bounds().Dy() != lookEdge/2 {
|
||||
t.Errorf("the picture sent is %v (%v), not shrunk to %d", im.Bounds(), err, lookEdge)
|
||||
}
|
||||
}
|
||||
|
||||
// noisePNG is a picture that does not compress: n by n, so its base64 is
|
||||
// past any small limit.
|
||||
func noisePNG(t *testing.T, n int) []byte {
|
||||
t.Helper()
|
||||
im := image.NewRGBA(image.Rect(0, 0, n, n))
|
||||
seed := uint32(1)
|
||||
for i := range im.Pix {
|
||||
seed = seed*1664525 + 1013904223
|
||||
im.Pix[i] = byte(seed >> 24)
|
||||
}
|
||||
var b bytes.Buffer
|
||||
_ = png.Encode(&b, im)
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func TestAPictureOfRealSizeGoesThroughMCPAndByFileID(t *testing.T) {
|
||||
c := newMeshComfy(t)
|
||||
rt, secret := meshRouter(t, c)
|
||||
h := rt.Handler()
|
||||
pic := noisePNG(t, 400) // ~640 KB, as a 1024 px concept is
|
||||
res, _ := mcp(t, h, secret, "tools/call", map[string]any{"name": "generate_mesh", "arguments": map[string]any{
|
||||
"model": "trellis2", "image": "data:image/png;base64," + base64.StdEncoding.EncodeToString(pic)}}, false)
|
||||
if info := resultInfo(t, res); info["status"] != "completed" {
|
||||
t.Fatalf("a mesh from a picture of real size: %v", info)
|
||||
}
|
||||
|
||||
// Uploaded once as a picture to work from, then named by id.
|
||||
var form bytes.Buffer
|
||||
mw := multipart.NewWriter(&form)
|
||||
_ = mw.WriteField("purpose", "vision")
|
||||
fw, _ := mw.CreateFormFile("file", "rock.png")
|
||||
_, _ = fw.Write(pic)
|
||||
_ = mw.Close()
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/files", &form)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
req.Header.Set("Authorization", "Bearer "+secret)
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
var f map[string]any
|
||||
if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &f) != nil || f["purpose"] != "vision" ||
|
||||
f["expires_at"] == nil {
|
||||
t.Fatalf("a picture uploaded to work from: %d %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
res, _ = mcp(t, h, secret, "tools/call", map[string]any{"name": "generate_mesh", "arguments": map[string]any{
|
||||
"model": "trellis2", "image": f["id"]}}, false)
|
||||
if info := resultInfo(t, res); info["status"] != "completed" {
|
||||
t.Fatalf("a mesh from an uploaded picture: %v", info)
|
||||
}
|
||||
}
|
||||
|
||||
func TestACallTooLargeIsSaidSo(t *testing.T) {
|
||||
h := newTestRouter(t, baseConfig()).Handler()
|
||||
big := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"x","arguments":{"image":"` +
|
||||
strings.Repeat("A", mcpBody) + `"}}}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/mcp", strings.NewReader(big))
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusRequestEntityTooLarge || !strings.Contains(rec.Body.String(), "larger than") {
|
||||
t.Errorf("%d %s", rec.Code, rec.Body.String()[:min(200, rec.Body.Len())])
|
||||
}
|
||||
}
|
||||
|
||||
func TestACallToAColdModelWaitsItOutSayingSo(t *testing.T) {
|
||||
rt := newTestRouter(t, baseConfig())
|
||||
_ = rt.Handler()
|
||||
calls := 0
|
||||
var fake http.Handler = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
if calls < 3 {
|
||||
w.Header().Set("Retry-After", "1")
|
||||
w.Header().Set(waitingForHeader, "sf3d")
|
||||
w.Header().Set(etaHeader, "40")
|
||||
writeError(w, http.StatusServiceUnavailable, "server_error", "model loading: waiting")
|
||||
return
|
||||
}
|
||||
w.Header().Set("x-llmgw-seed", "4")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
})
|
||||
rt.selfHandler.Store(&fake)
|
||||
var said []string
|
||||
c := mediaCall{req: httptest.NewRequest(http.MethodPost, "/mcp", nil), streamed: true,
|
||||
progress: func(_ float64, m string) { said = append(said, m) }}
|
||||
var out map[string]any
|
||||
h, err := rt.selfJSONWaiting(c, "flux-schnell", imagesPath, map[string]any{}, &out)
|
||||
if err != nil || out["ok"] != true || h.Get("x-llmgw-seed") != "4" || calls != 3 {
|
||||
t.Fatalf("%v %v after %d calls", err, out, calls)
|
||||
}
|
||||
if len(said) != 2 || !strings.Contains(said[0], "waiting for sf3d to give up the GPU, about 40 s") {
|
||||
t.Errorf("said %q", said)
|
||||
}
|
||||
|
||||
// Refused for good — no Retry-After — is said at once.
|
||||
calls = 0
|
||||
fake = http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
calls++
|
||||
writeError(w, http.StatusPaymentRequired, "insufficient_quota", "the budget is spent")
|
||||
})
|
||||
rt.selfHandler.Store(&fake)
|
||||
if _, err := rt.selfJSONWaiting(c, "flux-schnell", imagesPath, map[string]any{}, &out); err == nil ||
|
||||
!strings.Contains(err.Error(), "budget") || calls != 1 {
|
||||
t.Errorf("%v after %d calls", err, calls)
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@ package router
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -255,7 +257,17 @@ var mcpTools = []map[string]any{
|
||||
|
||||
// handleMCP serves POST /mcp.
|
||||
func (r *Router) handleMCP(w http.ResponseWriter, req *http.Request) {
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, req.Body, maxMemoryBody))
|
||||
// Pictures to make a mesh from or to look at come in the call, as
|
||||
// base64.
|
||||
raw, err := io.ReadAll(http.MaxBytesReader(w, req.Body, mcpBody))
|
||||
if mbe := (*http.MaxBytesError)(nil); errors.As(err, &mbe) {
|
||||
writeJSON(w, http.StatusRequestEntityTooLarge, rpcResponse{
|
||||
JSONRPC: "2.0", ID: json.RawMessage("null"),
|
||||
Error: &rpcError{Code: -32600, Message: fmt.Sprintf("the call is larger than %d MB: send pictures smaller, "+
|
||||
"or fewer at once", mcpBody>>20)},
|
||||
})
|
||||
return
|
||||
}
|
||||
var rpc rpcRequest
|
||||
if err != nil || json.Unmarshal(raw, &rpc) != nil || rpc.Method == "" {
|
||||
writeJSON(w, http.StatusBadRequest, rpcResponse{
|
||||
@@ -273,23 +285,66 @@ func (r *Router) handleMCP(w http.ResponseWriter, req *http.Request) {
|
||||
resp := rpcResponse{JSONRPC: "2.0", ID: rpc.ID}
|
||||
switch rpc.Method {
|
||||
case "initialize":
|
||||
resp.Result = map[string]any{
|
||||
"protocolVersion": mcpProtocolVer,
|
||||
result := map[string]any{
|
||||
"protocolVersion": mcpVersionFor(rpc.Params),
|
||||
"capabilities": map[string]any{"tools": map[string]any{}},
|
||||
"serverInfo": map[string]any{"name": "llmgw-memory", "version": "1"},
|
||||
"serverInfo": map[string]any{"name": "llmgw", "version": "1"},
|
||||
}
|
||||
if r.mediaModels().any() {
|
||||
result["instructions"] = mcpMediaInstructions
|
||||
}
|
||||
resp.Result = result
|
||||
case "ping":
|
||||
resp.Result = map[string]any{}
|
||||
case "tools/list":
|
||||
resp.Result = map[string]any{"tools": mcpTools}
|
||||
resp.Result = map[string]any{"tools": append(slices.Clone(mcpTools), r.mediaTools()...)}
|
||||
case "tools/call":
|
||||
resp.Result = r.callMemoryTool(req, rpc.Params)
|
||||
var call struct {
|
||||
Name string `json:"name"`
|
||||
Arguments json.RawMessage `json:"arguments"`
|
||||
Meta struct {
|
||||
ProgressToken json.RawMessage `json:"progressToken"`
|
||||
} `json:"_meta"`
|
||||
}
|
||||
_ = json.Unmarshal(rpc.Params, &call)
|
||||
if !isMediaTool(call.Name) {
|
||||
resp.Result = r.callMemoryTool(req, rpc.Params)
|
||||
break
|
||||
}
|
||||
if acceptsStream(req) {
|
||||
// Answered as a stream: how far along it is while it runs, which
|
||||
// a mesh's minutes want, then the answer.
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
sw := &streamWriter{w: w, token: call.Meta.ProgressToken}
|
||||
stop := make(chan struct{})
|
||||
go sw.keepAlive(stop, 15*time.Second)
|
||||
resp.Result = r.callMediaTool(mediaCall{req: req, progress: sw.progress, streamed: true}, call.Name, call.Arguments)
|
||||
close(stop)
|
||||
sw.event(resp)
|
||||
return
|
||||
}
|
||||
resp.Result = r.callMediaTool(mediaCall{req: req, progress: func(float64, string) {}}, call.Name, call.Arguments)
|
||||
default:
|
||||
resp.Error = &rpcError{Code: -32601, Message: "method not found: " + rpc.Method}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// mcpVersionFor is the protocol version answered: the client's, of those
|
||||
// this server speaks, else its own.
|
||||
func mcpVersionFor(params json.RawMessage) string {
|
||||
var p struct {
|
||||
ProtocolVersion string `json:"protocolVersion"`
|
||||
}
|
||||
_ = json.Unmarshal(params, &p)
|
||||
if slices.Contains([]string{"2025-03-26", "2025-06-18"}, p.ProtocolVersion) {
|
||||
return p.ProtocolVersion
|
||||
}
|
||||
return mcpProtocolVer
|
||||
}
|
||||
|
||||
func toolText(text string, isError bool) map[string]any {
|
||||
return map[string]any{
|
||||
"content": []map[string]any{{"type": "text", "text": text}},
|
||||
|
||||
@@ -101,7 +101,8 @@ func (r *Router) generateMeshes(ctx context.Context, w http.ResponseWriter, req
|
||||
if err != nil {
|
||||
return proxy.Outcome{}, err
|
||||
}
|
||||
tpl, err := comfy.Load(ep.Workflow)
|
||||
wf, _ := ep.ComfyFor(req.Model)
|
||||
tpl, err := comfy.Load(wf)
|
||||
if err != nil {
|
||||
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -1134,8 +1138,10 @@ func (r *Router) dispatchOne(ctx context.Context, w http.ResponseWriter, req *pa
|
||||
trace.WithAttributes(attribute.String("llmgw.endpoint", dec.Endpoint.ID),
|
||||
attribute.String("url.full", dec.Endpoint.URL+req.Path)))
|
||||
body := req.UpstreamBody
|
||||
if dec.Endpoint.UpstreamModel != "" && req.ContentType == "" {
|
||||
body = withModel(body, dec.Endpoint.UpstreamModel)
|
||||
// A shared ComfyUI replica is told which of its models' checkpoint;
|
||||
// any other endpoint its own upstream name.
|
||||
if _, up := dec.Endpoint.ComfyFor(model); up != "" && req.ContentType == "" {
|
||||
body = withModel(body, up)
|
||||
}
|
||||
// llama-server is asked for the model with the adapter applied to
|
||||
// this request; vLLM serves each adapter as a model of its own name,
|
||||
|
||||
@@ -297,7 +297,8 @@ func (r *Router) generateRun(ctx context.Context, w http.ResponseWriter, req *pa
|
||||
if err != nil {
|
||||
return proxy.Outcome{}, err
|
||||
}
|
||||
tpl, err := comfy.Load(ep.Workflow)
|
||||
wf, _ := ep.ComfyFor(req.Model)
|
||||
tpl, err := comfy.Load(wf)
|
||||
if err != nil {
|
||||
return proxy.Outcome{}, fmt.Errorf("%s: %w", ep.ID, err)
|
||||
}
|
||||
|
||||
+17
-3
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"slices"
|
||||
@@ -342,7 +343,14 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) {
|
||||
// between the listing and the click, or a mirror that has gone
|
||||
// down since, both end as a replica failing on a node minutes
|
||||
// later with an error naming the node. Asking costs one HEAD.
|
||||
if _, err := sourcecheck.Check(ctx, model.Source); err != nil {
|
||||
size, err := sourcecheck.Check(ctx, model.Source)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// The weights and the context's cache have to fit the card:
|
||||
// an 8B asked for a 131072-token pool on 24 GB loaded its
|
||||
// weights and failed to make its context, again and again.
|
||||
if err := p.fitsCard(ctx, l, model, size); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := d.Catalog.Add(ctx, model, admin.Email); err != nil {
|
||||
@@ -365,6 +373,14 @@ func (p *AccountPages) RegisterAdminMirror(mux *http.ServeMux, d MirrorDeps) {
|
||||
mux.HandleFunc("POST /admin/mirror/{model}/{version}/remove", p.mirrorAction(m,
|
||||
func(ctx context.Context, _ store.User, r *http.Request) (string, error) {
|
||||
model, ver := r.PathValue("model"), r.PathValue("version")
|
||||
// Weights a catalog model still loads are not taken from under
|
||||
// it: removed from the mirror alone, the model stayed in the
|
||||
// catalog and its replicas went on loading the copy on the node.
|
||||
if users := p.mirrorUsers(ctx, model, ver); len(users) > 0 {
|
||||
return "", fmt.Errorf("%s/%s is the weights of %s in the catalog: remove %s first "+
|
||||
"(the models page, or llmgw ctl model remove %s), then the weights",
|
||||
model, ver, strings.Join(users, ", "), strings.Join(users, ", "), users[0])
|
||||
}
|
||||
removed, err := m.Remove(ctx, model, ver)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -937,3 +953,52 @@ func sharedPrefix(ids []string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mirrorUsers are the catalog models whose weights are the mirror's
|
||||
// model/version.
|
||||
func (p *AccountPages) mirrorUsers(ctx context.Context, model, ver string) []string {
|
||||
if p.catalog == nil {
|
||||
return nil
|
||||
}
|
||||
srcs, err := p.catalog.Sources(ctx)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
part := "/" + model + "/" + ver + "/"
|
||||
var out []string
|
||||
for src, ms := range srcs {
|
||||
if !strings.Contains(src, part) {
|
||||
continue
|
||||
}
|
||||
for _, m := range ms {
|
||||
if !slices.Contains(out, m.Name) {
|
||||
out = append(out, m.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// fitsCard refuses a model whose weights and context pool do not fit the
|
||||
// cards its profile names, saying the largest pool that would.
|
||||
func (p *AccountPages) fitsCard(ctx context.Context, l *Lang, m catalog.Model, size int64) error {
|
||||
if len(m.Profiles) == 0 || m.Profiles[0].GPUClass == "" || size <= 0 {
|
||||
return nil
|
||||
}
|
||||
pr := m.Profiles[0]
|
||||
v := p.checkSource(ctx, m.Source)
|
||||
cards := max(pr.GPUsPerNode, pr.TP, 1)
|
||||
most := v.Header.PoolThatFits(size, gguf.ClassMemGB(pr.GPUClass), cards)
|
||||
pool := argInt(pr.Args, "--ctx-size")
|
||||
if pool <= 0 {
|
||||
pool = defaultCtxPool
|
||||
}
|
||||
switch {
|
||||
case most < 0:
|
||||
return errors.New(l.T("serve.err.weights_too_big", l.Num(float64(size)/(1<<30), 1), pr.GPUClass))
|
||||
case most > 0 && pool > most:
|
||||
return errors.New(l.T("serve.err.pool_too_big", l.Int(int64(pool)), pr.GPUClass, l.Int(int64(most/1024*1024))))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -239,14 +239,14 @@ func TestThePageReloadsOnlyWhileSomethingIsDownloading(t *testing.T) {
|
||||
|
||||
// A reload while nothing is happening would take with it whatever the
|
||||
// operator was typing into the fetch form.
|
||||
if strings.Contains(idle, "http-equiv=\"refresh\"") {
|
||||
if strings.Contains(idle, "name=\"llmgw-refresh\"") {
|
||||
t.Error("an idle page should not reload itself")
|
||||
}
|
||||
|
||||
busy := mirrorGet(t, mirrorPage(t, &fakeMirror{models: []mirror.Status{
|
||||
{Model: "m", Version: "v", File: "f", State: mirror.Fetching},
|
||||
}}, true), "/admin/mirror").Body.String()
|
||||
if !strings.Contains(busy, "http-equiv=\"refresh\"") {
|
||||
if !strings.Contains(busy, "name=\"llmgw-refresh\"") {
|
||||
t.Error("a page with a download running should follow it")
|
||||
}
|
||||
}
|
||||
@@ -680,7 +680,7 @@ func TestEachTabDoesOnlyItsOwnWork(t *testing.T) {
|
||||
if strings.Contains(held, `name="source" required`) || strings.Contains(held, "Search Hugging Face") {
|
||||
t.Error("other tabs' forms are on the list")
|
||||
}
|
||||
if !strings.Contains(held, `http-equiv="refresh"`) {
|
||||
if !strings.Contains(held, `name="llmgw-refresh"`) {
|
||||
t.Error("the list stopped following a download")
|
||||
}
|
||||
|
||||
@@ -688,7 +688,7 @@ func TestEachTabDoesOnlyItsOwnWork(t *testing.T) {
|
||||
if reg.explored != 1 || !strings.Contains(explore, "Search Hugging Face") {
|
||||
t.Error("the explorer tab did not explore")
|
||||
}
|
||||
if strings.Contains(explore, `id="held"`) || strings.Contains(explore, `http-equiv="refresh"`) {
|
||||
if strings.Contains(explore, `id="held"`) || strings.Contains(explore, `name="llmgw-refresh"`) {
|
||||
t.Error("the explorer shows the list, or reloads away a search")
|
||||
}
|
||||
// A link from before there were tabs still lands on the explorer.
|
||||
@@ -766,3 +766,28 @@ func TestAJudgesWeightsAreServedAsTheJudgeThePoliciesAsk(t *testing.T) {
|
||||
t.Errorf("a model clients use became a judge: %+v", plain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeightsAModelStillLoadsAreNotRemoved(t *testing.T) {
|
||||
// Removed from the mirror alone, a model stayed in the catalog and its
|
||||
// replicas went on loading the copy on the node, with nothing said.
|
||||
m := &fakeMirror{}
|
||||
cat := &fakeCatalog{sources: map[string][]catalog.Model{
|
||||
"http://10.0.0.146:8081/apertus-70b/aff98136/Apertus-70B-UD-IQ1_M.gguf": {{Name: "apertus-70b-instruct-2509"}},
|
||||
}}
|
||||
a := fakeAccounts{user: store.User{ID: "u1", Email: "a@example.com", IsAdmin: true}}
|
||||
p := NewAccountPages(a, nil, nil, "v0", slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
mux := http.NewServeMux()
|
||||
p.RegisterAdminMirror(mux, MirrorDeps{Mirror: m, Catalog: cat})
|
||||
body := mirrorPost(t, mux, "/admin/mirror/apertus-70b/aff98136/remove", url.Values{"csrf": {"tok"}}).Body.String()
|
||||
if m.removed != [2]string{} {
|
||||
t.Errorf("weights in use were removed: %v", m.removed)
|
||||
}
|
||||
if !strings.Contains(body, "apertus-70b-instruct-2509") || !strings.Contains(body, "remove") {
|
||||
t.Errorf("the page does not say which model uses them: %s", body)
|
||||
}
|
||||
// Weights nothing loads go as before.
|
||||
mirrorPost(t, mux, "/admin/mirror/other/v1/remove", url.Values{"csrf": {"tok"}})
|
||||
if m.removed != [2]string{"other", "v1"} {
|
||||
t.Errorf("unused weights were not removed: %v", m.removed)
|
||||
}
|
||||
}
|
||||
@@ -73,6 +73,8 @@
|
||||
"serve.err.engine": "which engine should run it?",
|
||||
"serve.err.needs_gpu": "%s runs on GPUs; choose a GPU class to place it on",
|
||||
"serve.err.placement": "%q is not a placement",
|
||||
"serve.err.weights_too_big": "Its weights (%s GB) do not fit a %s card with room to run: choose a smaller quantisation, or more cards.",
|
||||
"serve.err.pool_too_big": "A context pool of %s tokens does not fit a %s card beside these weights; at most %s would.",
|
||||
"serve.err.cpu_with_free_gpus.one": "this fleet has %d GPU free and %s was set to run on the CPU; choose a GPU placement, or tick \"run on the CPU anyway\"",
|
||||
"serve.err.cpu_with_free_gpus.other": "this fleet has %d GPUs free and %s was set to run on the CPU; choose a GPU placement, or tick \"run on the CPU anyway\"",
|
||||
"serve.msg.added": "%s added to the catalog, %s: %s; it is placed on the next pass",
|
||||
|
||||
@@ -72,6 +72,8 @@
|
||||
"serve.err.engine": "hangi motor çalıştıracak?",
|
||||
"serve.err.needs_gpu": "%s GPU üzerinde çalışır; yerleştirmek için bir GPU sınıfı seçin",
|
||||
"serve.err.placement": "%q geçerli bir yerleşim değil",
|
||||
"serve.err.weights_too_big": "Ağırlıkları (%s GB) bir %s kartına çalışacak payla sığmıyor: daha küçük bir quantization ya da daha fazla kart seçin.",
|
||||
"serve.err.pool_too_big": "%s token'lık bağlam havuzu bu ağırlıklarla bir %s kartına sığmıyor; en fazla %s sığar.",
|
||||
"serve.err.cpu_with_free_gpus.other": "bu kümede %d GPU boşta, %s ise CPU üzerinde çalışacak biçimde ayarlandı; bir GPU yerleşimi seçin ya da \"yine de CPU üzerinde çalıştır\" kutusunu işaretleyin",
|
||||
"serve.msg.added": "%s kataloğa eklendi, %s: %s; bir sonraki geçişte yerleştirilecek",
|
||||
"serve.replicas_n.other": "%d replika",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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