Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e91868ee | ||
|
|
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
|
- 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).
|
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:
|
- `License` gains `id` (SPDX where there is one, else a lowercase name:
|
||||||
stability-community, tencent-hunyuan-community), `commercial` (unset is
|
stability-community, tencent-hunyuan-community), `commercial` (unset is
|
||||||
not said), `excluded_regions` (ISO 3166 codes, `EU` for the union),
|
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,
|
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.
|
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
|
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
|
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
|
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
|
store table is migration 0046. A site-wide or team-wide default can sit
|
||||||
on top of it later.
|
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
|
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).
|
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
|
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
|
environment, after H3) and a cluster-wide cap, with the lease and
|
||||||
leader-only rules of G1 kept.
|
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
|
## D. Facts worth knowing before starting
|
||||||
|
|
||||||
- Admin API: `127.0.0.1:9090`, no auth, `ctlClient` in `internal/cli/ctl.go`.
|
- Admin API: `127.0.0.1:9090`, no auth, `ctlClient` in `internal/cli/ctl.go`.
|
||||||
|
|||||||
@@ -26,6 +26,48 @@ APIs change between minors without a deprecation period.
|
|||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **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
|
||||||
|
|
||||||
|
- 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
|
## v0.14.5 — 2026-10-14
|
||||||
|
|
||||||
Picture and 3D models that add themselves where a card fits them, with a
|
Picture and 3D models that add themselves where a card fits them, with a
|
||||||
|
|||||||
@@ -10413,6 +10413,10 @@
|
|||||||
"type": "boolean",
|
"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."
|
"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": {
|
"cpu_cores": {
|
||||||
"type": "integer",
|
"type": "integer",
|
||||||
"description": "CPUCores is what a replica computes on; zero takes half the smallest node it fits on."
|
"description": "CPUCores is what a replica computes on; zero takes half the smallest node it fits on."
|
||||||
|
|||||||
@@ -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
|
refuses a thousand requests is one message. `llmgw ctl notify test` sends a
|
||||||
test event to each webhook and says how each answered.
|
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
|
### Tracing
|
||||||
|
|
||||||
Metrics say that a model took four minutes to start; a trace says where they
|
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) |
|
| `no_passwords` | `bool` | [OIDC](#oidc) |
|
||||||
| `mirror` | `string` | [Offline](#offline) |
|
| `mirror` | `string` | [Offline](#offline) |
|
||||||
| `checksums` | `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) |
|
| `nvidia_smi` | `string` | [Options](#options) |
|
||||||
| `ib_root` | `string` | [Options](#options) |
|
| `ib_root` | `string` | [Options](#options) |
|
||||||
| `prefer_nvml` | `bool` | [Options](#options) |
|
| `prefer_nvml` | `bool` | [Options](#options) |
|
||||||
@@ -346,6 +348,7 @@ rather than repeated.
|
|||||||
| `serving` | `ServingSettings` | [Server](#server) |
|
| `serving` | `ServingSettings` | [Server](#server) |
|
||||||
| `firewall` | `FirewallSettings` | [Server](#server) |
|
| `firewall` | `FirewallSettings` | [Server](#server) |
|
||||||
| `presets` | `PresetSettings` | [Server](#server) |
|
| `presets` | `PresetSettings` | [Server](#server) |
|
||||||
|
| `on_demand` | `OnDemandSettings` | [Server](#server) |
|
||||||
| `agents` | `AgentSettings` | [Server](#server) |
|
| `agents` | `AgentSettings` | [Server](#server) |
|
||||||
| `firewall_networks` | `[]string` | [Server](#server) |
|
| `firewall_networks` | `[]string` | [Server](#server) |
|
||||||
| `backup_recipients` | `[]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
|
a node fetches from the mirror is checked against it. Read again
|
||||||
whenever it changes. Empty is /etc/llmgw/offline.sha256.
|
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
|
## Options
|
||||||
|
|
||||||
### `nvidia_smi`
|
### `nvidia_smi`
|
||||||
@@ -2984,6 +3006,15 @@ Presets is how the models that come with llmgw reach the catalog.
|
|||||||
|
|
||||||
Takes a [PresetSettings](#presetsettings) block.
|
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`
|
### `agents`
|
||||||
|
|
||||||
`AgentSettings`
|
`AgentSettings`
|
||||||
|
|||||||
@@ -245,6 +245,8 @@ var liveSettings = []string{
|
|||||||
"agents",
|
"agents",
|
||||||
// Read by the leader's presets pass each time it runs.
|
// Read by the leader's presets pass each time it runs.
|
||||||
"presets",
|
"presets",
|
||||||
|
// Handed to the reconciler as they change.
|
||||||
|
"on_demand",
|
||||||
// Read by server init, node add and backups, not by the server.
|
// Read by server init, node add and backups, not by the server.
|
||||||
"backup_recipients", "backup_copy_to",
|
"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",
|
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)
|
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
|
// The one machine this does not reach. Said every time, because it is
|
||||||
// exactly the kind of thing that stays three versions behind unnoticed.
|
// 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"+
|
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.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.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.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.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.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")
|
f.StringVar(&req.GPUClass, "gpu-class", "", "run it on GPUs of this class, whatever the plan says")
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"gitea.mynodes.xyz/ilker/llmgw/internal/control/adminapi"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -70,6 +73,9 @@ func newSelfUpgradeCmd() *cobra.Command {
|
|||||||
if strings.TrimSpace(string(b)) == "active" {
|
if strings.TrimSpace(string(b)) == "active" {
|
||||||
if steady++; steady >= 3 {
|
if steady++; steady >= 3 {
|
||||||
fmt.Fprintf(out, "%s restarted and running\n", service)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -85,3 +91,25 @@ func newSelfUpgradeCmd() *cobra.Command {
|
|||||||
c.Flags().StringVar(&service, "service", "", "the systemd unit to restart afterwards, e.g. llmgw-mirror")
|
c.Flags().StringVar(&service, "service", "", "the systemd unit to restart afterwards, e.g. llmgw-mirror")
|
||||||
return c
|
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
|
agentSvc = svc
|
||||||
// Stopped by the operators (llmgw stop): nothing placed.
|
// Stopped by the operators (llmgw stop): nothing placed.
|
||||||
ctl.Reconciler().SetStopped(cfg.Serving.Stopped)
|
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.
|
// Whether nodes keep llmgw's firewall, as the settings say now.
|
||||||
svc.SetFirewall(func() *llmgwv1.Firewall {
|
svc.SetFirewall(func() *llmgwv1.Firewall {
|
||||||
c := current.Load()
|
c := current.Load()
|
||||||
@@ -778,6 +779,9 @@ func newServerCmd() *cobra.Command {
|
|||||||
fwPub.Kick()
|
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 {
|
if slices.ContainsFunc(changed, func(c string) bool { return strings.HasPrefix(c, "agents") }) && agentSvc != nil {
|
||||||
// A moment apart per node, so off this goroutine.
|
// A moment apart per node, so off this goroutine.
|
||||||
go agentSvc.BroadcastAgentSettings()
|
go agentSvc.BroadcastAgentSettings()
|
||||||
|
|||||||
@@ -73,6 +73,9 @@ type serverInit struct {
|
|||||||
// patroni are user@host of the machines to keep the store on under
|
// 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, replicated; this machine is the third etcd vote for two.
|
||||||
patroni []string
|
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
|
// patroniNodes are the same machines already connected to, which the
|
||||||
// setup screen does with the password it was given; when set, patroni
|
// setup screen does with the password it was given; when set, patroni
|
||||||
// only names them.
|
// 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")
|
"leave the firewall alone, instead of keeping llmgw's internal ports to the cluster's networks")
|
||||||
f.BoolVar(&in.postgres, "postgres", false,
|
f.BoolVar(&in.postgres, "postgres", false,
|
||||||
"keep the store in PostgreSQL on this machine, installing it and moving an existing SQLite store into it")
|
"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,
|
f.StringArrayVar(&in.patroni, "patroni", nil,
|
||||||
"user@host of a machine to keep the store on, replicated under Patroni; give two or more. "+
|
"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")
|
"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)
|
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) {
|
if in.judgeWanted() && in.configJudgesWith(guardModelName) {
|
||||||
// Not fatal: without its judge the baseline policy still checks for
|
// Not fatal: without its judge the baseline policy still checks for
|
||||||
// secrets and injection, and a judged request is served flagged.
|
// 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 (
|
const (
|
||||||
fAdmin = iota
|
fAdmin = iota
|
||||||
fHosts
|
fHosts
|
||||||
|
fWebhooks
|
||||||
fPostgres
|
fPostgres
|
||||||
fMirror
|
fMirror
|
||||||
fNode
|
fNode
|
||||||
@@ -70,6 +71,7 @@ type initModel struct {
|
|||||||
focus int
|
focus int
|
||||||
admin textField
|
admin textField
|
||||||
hosts textField
|
hosts textField
|
||||||
|
webhooks textField
|
||||||
bundle textField
|
bundle textField
|
||||||
bundleSHA textField
|
bundleSHA textField
|
||||||
toggles [fCount]bool
|
toggles [fCount]bool
|
||||||
@@ -103,6 +105,11 @@ func newInitModel(in *serverInit, found []string, askForm bool) initModel {
|
|||||||
hosts.SetValue(strings.Join(in.hosts, ", "))
|
hosts.SetValue(strings.Join(in.hosts, ", "))
|
||||||
hosts.CharLimit = 400
|
hosts.CharLimit = 400
|
||||||
hosts.Width = 40
|
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 := newTextField()
|
||||||
bundle.Placeholder = "/media/usb/llmgw-bundle.tar"
|
bundle.Placeholder = "/media/usb/llmgw-bundle.tar"
|
||||||
bundle.SetValue(in.bundle)
|
bundle.SetValue(in.bundle)
|
||||||
@@ -114,7 +121,7 @@ func newInitModel(in *serverInit, found []string, askForm bool) initModel {
|
|||||||
bundleSHA.CharLimit = 64
|
bundleSHA.CharLimit = 64
|
||||||
bundleSHA.Width = 64
|
bundleSHA.Width = 64
|
||||||
sp := newSpinner()
|
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}
|
bundle: bundle, bundleSHA: bundleSHA, spin: sp, width: 100}
|
||||||
m.toggles[fPostgres] = in.postgres
|
m.toggles[fPostgres] = in.postgres
|
||||||
m.toggles[fMirror] = !in.noMirror
|
m.toggles[fMirror] = !in.noMirror
|
||||||
@@ -235,6 +242,17 @@ func (m initModel) formKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.moveFocus(0)
|
m.moveFocus(0)
|
||||||
return m, nil
|
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.bundle, m.in.bundleSHA = bundle, sum
|
||||||
m.in.admin = admin
|
m.in.admin = admin
|
||||||
m.in.hosts = nil
|
m.in.hosts = nil
|
||||||
@@ -260,6 +278,10 @@ func (m initModel) formKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.hosts, cmd = m.hosts.Update(k)
|
m.hosts, cmd = m.hosts.Update(k)
|
||||||
return m, cmd
|
return m, cmd
|
||||||
|
case fWebhooks:
|
||||||
|
var cmd tea.Cmd
|
||||||
|
m.webhooks, cmd = m.webhooks.Update(k)
|
||||||
|
return m, cmd
|
||||||
case fBundle:
|
case fBundle:
|
||||||
var cmd tea.Cmd
|
var cmd tea.Cmd
|
||||||
m.bundle, cmd = m.bundle.Update(k)
|
m.bundle, cmd = m.bundle.Update(k)
|
||||||
@@ -284,6 +306,7 @@ func (m *initModel) moveFocus(d int) {
|
|||||||
}
|
}
|
||||||
m.admin.Blur()
|
m.admin.Blur()
|
||||||
m.hosts.Blur()
|
m.hosts.Blur()
|
||||||
|
m.webhooks.Blur()
|
||||||
m.bundle.Blur()
|
m.bundle.Blur()
|
||||||
m.bundleSHA.Blur()
|
m.bundleSHA.Blur()
|
||||||
switch m.focus {
|
switch m.focus {
|
||||||
@@ -291,6 +314,8 @@ func (m *initModel) moveFocus(d int) {
|
|||||||
m.admin.Focus()
|
m.admin.Focus()
|
||||||
case fHosts:
|
case fHosts:
|
||||||
m.hosts.Focus()
|
m.hosts.Focus()
|
||||||
|
case fWebhooks:
|
||||||
|
m.webhooks.Focus()
|
||||||
case fBundle:
|
case fBundle:
|
||||||
m.bundle.Focus()
|
m.bundle.Focus()
|
||||||
case fBundleSHA:
|
case fBundleSHA:
|
||||||
@@ -409,6 +434,8 @@ func (m initModel) formView() string {
|
|||||||
found = strings.Join(m.found, ", ")
|
found = strings.Join(m.found, ", ")
|
||||||
}
|
}
|
||||||
b.WriteString(" " + sDim.Render("Already in the certificate: "+found+". Add a DNS name or NAT address here.") + "\n\n")
|
b.WriteString(" " + 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"))
|
b.WriteString(check(fPostgres, "PostgreSQL", "rather than SQLite; installed here, for several control planes or a larger store"))
|
||||||
if m.in.mirrorURL != "" {
|
if m.in.mirrorURL != "" {
|
||||||
b.WriteString(" Model mirror " + sDim.Render("at "+m.in.mirrorURL+", from --mirror") + "\n")
|
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 = typeIn(m, "ops@example.com")
|
||||||
m = send(m, tea.KeyMsg{Type: tea.KeyTab})
|
m = send(m, tea.KeyMsg{Type: tea.KeyTab})
|
||||||
m = typeIn(m, "llm.example.com, 203.0.113.10")
|
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.KeyRunes, Runes: []rune(" ")}) // PostgreSQL on
|
||||||
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab},
|
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
|
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 {
|
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)
|
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 {
|
if m.(initModel).form {
|
||||||
t.Error("the form stayed up after Set up")
|
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) {
|
func TestAnOfflineBundleIsGivenInTheFormWithItsDigest(t *testing.T) {
|
||||||
in := &serverInit{}
|
in := &serverInit{}
|
||||||
var m tea.Model = newInitModel(in, nil, true)
|
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") {
|
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)
|
t.Errorf("the mirror in use is not shown:\n%s", v)
|
||||||
}
|
}
|
||||||
// Tab from the addresses goes past the mirror line to PostgreSQL, then
|
// Tab from the addresses goes to the webhooks, past the mirror line to
|
||||||
// the node.
|
// PostgreSQL, then the node.
|
||||||
m = send(m, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab}, tea.KeyMsg{Type: tea.KeyTab})
|
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 {
|
if f := m.(initModel).focus; f != fNode {
|
||||||
t.Errorf("focus %d, want the node toggle %d", f, fNode)
|
t.Errorf("focus %d, want the node toggle %d", f, fNode)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import (
|
|||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
joinpkg "gitea.mynodes.xyz/ilker/llmgw/internal/join"
|
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/onboard"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/pgha"
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/preflight"
|
||||||
@@ -150,8 +151,8 @@ type setupModel struct {
|
|||||||
ips []string
|
ips []string
|
||||||
choice int // 0 one machine, 1 high availability
|
choice int // 0 one machine, 1 high availability
|
||||||
|
|
||||||
admin, names textField
|
admin, names, webhooks textField
|
||||||
afocus int // admin, names, next
|
afocus int // admin, names, webhooks, next
|
||||||
|
|
||||||
db [2]dbMachine
|
db [2]dbMachine
|
||||||
mfocus int // over both machines' fields, then the check button
|
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
|
admin.Placeholder, admin.CharLimit, admin.Width = "you@example.com", 200, 40
|
||||||
names := newTextField()
|
names := newTextField()
|
||||||
names.Placeholder, names.CharLimit, names.Width = "llm.example.com, 203.0.113.10", 400, 40
|
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{
|
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,
|
db: [2]dbMachine{newDBMachine(1), newDBMachine(2)}, spin: newSpinner(), width: 100, height: 30,
|
||||||
log: &[]string{}, logMu: &sync.Mutex{},
|
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) {
|
func (m setupModel) adminKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
move := func(d int) {
|
move := func(d int) {
|
||||||
m.afocus = (m.afocus + d + 3) % 3
|
m.afocus = (m.afocus + d + 4) % 4
|
||||||
m.admin.Blur()
|
m.admin.Blur()
|
||||||
m.names.Blur()
|
m.names.Blur()
|
||||||
|
m.webhooks.Blur()
|
||||||
switch m.afocus {
|
switch m.afocus {
|
||||||
case 0:
|
case 0:
|
||||||
m.admin.Focus()
|
m.admin.Focus()
|
||||||
case 1:
|
case 1:
|
||||||
m.names.Focus()
|
m.names.Focus()
|
||||||
|
case 2:
|
||||||
|
m.webhooks.Focus()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
switch k.Type {
|
switch k.Type {
|
||||||
@@ -326,7 +332,7 @@ func (m setupModel) adminKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
move(-1)
|
move(-1)
|
||||||
return m, nil
|
return m, nil
|
||||||
case tea.KeyEnter:
|
case tea.KeyEnter:
|
||||||
if m.afocus < 2 {
|
if m.afocus < 3 {
|
||||||
move(1)
|
move(1)
|
||||||
return m, nil
|
return m, nil
|
||||||
}
|
}
|
||||||
@@ -336,6 +342,12 @@ func (m setupModel) adminKey(k tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
move(0)
|
move(0)
|
||||||
return m, nil
|
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.formErr = ""
|
||||||
m.page, m.mfocus = pMachines, 0
|
m.page, m.mfocus = pMachines, 0
|
||||||
m.db[0].addr.Focus()
|
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)
|
m.admin, cmd = m.admin.Update(k)
|
||||||
case 1:
|
case 1:
|
||||||
m.names, cmd = m.names.Update(k)
|
m.names, cmd = m.names.Update(k)
|
||||||
|
case 2:
|
||||||
|
m.webhooks, cmd = m.webhooks.Update(k)
|
||||||
}
|
}
|
||||||
return m, cmd
|
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() {
|
func (m *setupModel) focusMachine() {
|
||||||
for i := range m.db {
|
for i := range m.db {
|
||||||
for f := 0; f < mfCount; f++ {
|
for f := 0; f < mfCount; f++ {
|
||||||
@@ -589,6 +614,9 @@ func (m setupModel) command() string {
|
|||||||
for _, t := range m.targets() {
|
for _, t := range m.targets() {
|
||||||
parts = append(parts, "--patroni "+t)
|
parts = append(parts, "--patroni "+t)
|
||||||
}
|
}
|
||||||
|
for _, w := range m.webhookList() {
|
||||||
|
parts = append(parts, "--webhook "+w)
|
||||||
|
}
|
||||||
parts = append(parts, "--no-node", "--no-model")
|
parts = append(parts, "--no-node", "--no-model")
|
||||||
return strings.Join(parts, " \\\n ")
|
return strings.Join(parts, " \\\n ")
|
||||||
}
|
}
|
||||||
@@ -600,6 +628,7 @@ func (m *setupModel) startRun() tea.Cmd {
|
|||||||
in.admin = strings.TrimSpace(m.admin.Value())
|
in.admin = strings.TrimSpace(m.admin.Value())
|
||||||
in.hosts = m.namesList()
|
in.hosts = m.namesList()
|
||||||
in.patroni = m.targets()
|
in.patroni = m.targets()
|
||||||
|
in.webhooks = m.webhookList()
|
||||||
// The control plane of a replicated setup is not where models run;
|
// The control plane of a replicated setup is not where models run;
|
||||||
// nodes are added once it is up.
|
// nodes are added once it is up.
|
||||||
in.noNode, in.noModel = true, true
|
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."))
|
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(),
|
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."))
|
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()
|
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()),
|
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)")
|
"control plane and gateway, model mirror, etcd (the third vote)")
|
||||||
fmt.Fprintf(&b, " administrator %s\n", strings.TrimSpace(m.admin.Value()))
|
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
|
granted := false
|
||||||
for _, d := range m.db {
|
for _, d := range m.db {
|
||||||
addr := strings.TrimSpace(d.addr.Value())
|
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)
|
t.Fatalf("high availability did not lead to the administrator: page %d", s.page)
|
||||||
}
|
}
|
||||||
// No email: refused, with a reason.
|
// 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") {
|
if s := m.(setupModel); s.page != pAdmin || !strings.Contains(s.formErr, "email") {
|
||||||
t.Fatalf("went on without an administrator: %+v", s.formErr)
|
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") {
|
if !strings.Contains(m.View(), "Not yet") {
|
||||||
t.Error("the refusal was not shown as a dialog")
|
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)
|
s := m.(setupModel)
|
||||||
if s.page != pMachines {
|
if s.page != pMachines {
|
||||||
t.Fatalf("page %d: %s", s.page, s.formErr)
|
t.Fatalf("page %d: %s", s.page, s.formErr)
|
||||||
|
|||||||
@@ -161,6 +161,10 @@ type Server struct {
|
|||||||
// Presets is how the models that come with llmgw reach the catalog.
|
// Presets is how the models that come with llmgw reach the catalog.
|
||||||
Presets PresetSettings `koanf:"presets"`
|
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
|
// Agents are the nodes' agent settings: agent.yaml's keys but those an
|
||||||
// agent needs to start, for every node (default) and for one
|
// agent needs to start, for every node (default) and for one
|
||||||
// (nodes.<id>). Sent to the agents as they change; an agent whose
|
// (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"`
|
||||||
|
}
|
||||||
@@ -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")
|
s.step(j, "smoke test", Running, "asking "+r.ID+" to say hello")
|
||||||
var reply string
|
var reply string
|
||||||
var tps float64
|
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))
|
s.step(j, "smoke test", Done, fmt.Sprintf("answered at %.1f tokens/s: %q", tps, reply))
|
||||||
} else {
|
} else {
|
||||||
s.step(j, "smoke test", Failed, err.Error())
|
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
|
// is written into the catalog, where clients and editors read it. Check
|
||||||
// a running replica with llmgw ctl tool-check before saying so.
|
// a running replica with llmgw ctl tool-check before saying so.
|
||||||
Tools bool `json:"tools,omitempty"`
|
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
|
// CPUCores is what a replica computes on; zero takes half the smallest
|
||||||
// node it fits on.
|
// node it fits on.
|
||||||
CPUCores int `json:"cpu_cores,omitempty"`
|
CPUCores int `json:"cpu_cores,omitempty"`
|
||||||
@@ -268,6 +275,7 @@ func (s *Server) Start(req Request) (Job, error) {
|
|||||||
if req.GuardFormat != "" && req.Replicas == 0 {
|
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")
|
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 {
|
if req.CtxPool <= 0 {
|
||||||
req.CtxPool = 16384
|
req.CtxPool = 16384
|
||||||
}
|
}
|
||||||
@@ -473,6 +481,16 @@ func (s *Server) run(j *Job) {
|
|||||||
}
|
}
|
||||||
header = v.Header
|
header = v.Header
|
||||||
}
|
}
|
||||||
|
if header.Embedding {
|
||||||
|
req.Embeddings = true
|
||||||
|
}
|
||||||
|
if req.Embeddings && !req.sized {
|
||||||
|
// Many passages at once rather than four: an import of a hundred
|
||||||
|
// thousand chunks went at the pace of four slots with the card a
|
||||||
|
// third busy.
|
||||||
|
req.ContextLength, req.CtxPool = embedSlotTokens, embedSlotTokens*embedSlots
|
||||||
|
}
|
||||||
|
j.Request = req
|
||||||
memGB := int((header.MemoryBytes(set.Bytes, req.CtxPool) + 1<<30 - 1) >> 30)
|
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",
|
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)
|
gb(set.Bytes), gb(int64(req.CtxPool)*header.KVBytesPerToken()), req.CtxPool)
|
||||||
@@ -587,10 +605,24 @@ func profilesFor(layout []modelplan.Option, req Request, memGB, cores int) []cat
|
|||||||
// slots of 8192, and the gateway, told 32768, sent requests the engine then
|
// slots of 8192, and the gateway, told 32768, sent requests the engine then
|
||||||
// refused.
|
// refused.
|
||||||
func llamaArgs(req Request) []string {
|
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))}
|
"--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
|
// 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
|
// context, from 1024 to 8192. 1024 everywhere cut an editor's agent off in
|
||||||
// the middle of the file it was writing.
|
// the middle of the file it was writing.
|
||||||
@@ -620,7 +652,7 @@ func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.F
|
|||||||
RoutingProfile: "chat", ContextLength: req.ContextLength, MaxOutput: maxOutput(req.ContextLength),
|
RoutingProfile: "chat", ContextLength: req.ContextLength, MaxOutput: maxOutput(req.ContextLength),
|
||||||
Profiles: profs,
|
Profiles: profs,
|
||||||
Scaling: catalog.Scaling{Min: req.Replicas, Max: max(req.Replicas, 1)},
|
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)},
|
Origin: &catalog.Origin{Repo: repo.ID, Revision: repo.Revision, Files: originFiles(set)},
|
||||||
}
|
}
|
||||||
if req.GuardFormat != "" {
|
if req.GuardFormat != "" {
|
||||||
@@ -655,7 +687,7 @@ func (s *Server) addAndStart(ctx context.Context, j *Job, repo hf.Repo, set hf.F
|
|||||||
|
|
||||||
// 6. Does it answer.
|
// 6. Does it answer.
|
||||||
s.step(j, "smoke test", Running, "asking "+rep.ID+" to say hello")
|
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 {
|
if err != nil {
|
||||||
s.fail(j, "smoke test", err)
|
s.fail(j, "smoke test", err)
|
||||||
return
|
return
|
||||||
@@ -859,7 +891,10 @@ func (s *Server) waitReady(ctx context.Context, j *Job, name string, hardware bo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// smoke sends one short request straight to the replica, with its engine key.
|
// 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{
|
body, _ := json.Marshal(map[string]any{
|
||||||
"model": name,
|
"model": name,
|
||||||
"max_tokens": 32,
|
"max_tokens": 32,
|
||||||
@@ -989,3 +1024,50 @@ func pickSafetensors(repo hf.Repo, _ Request) (hf.FileSet, error) {
|
|||||||
}
|
}
|
||||||
return set, nil
|
return set, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// smokeEmbeddings asks an embedding model for one vector, and says its
|
||||||
|
// length: an embedding model does not chat.
|
||||||
|
func (s *Server) smokeEmbeddings(ctx context.Context, r reconciler.Replica, name string) (string, float64, error) {
|
||||||
|
body, _ := json.Marshal(map[string]any{"model": name, "input": "hello"})
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
strings.TrimRight(r.Endpoint, "/")+"/v1/embeddings", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if k := s.deps.Fleet.EngineKey(r.ID); k != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+k)
|
||||||
|
}
|
||||||
|
resp, err := s.deps.HTTP.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", 0, fmt.Errorf("the replica answered %s: %s", resp.Status, strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
var out struct {
|
||||||
|
Data []struct {
|
||||||
|
Embedding []float64 `json:"embedding"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(b, &out); err != nil || len(out.Data) == 0 || len(out.Data[0].Embedding) == 0 {
|
||||||
|
return "", 0, fmt.Errorf("the replica answered no vector: %s", strings.TrimSpace(string(b)))
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("a vector of %d dimensions", len(out.Data[0].Embedding)), 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// embeds is whether the catalog serves name as an embedding model, for a
|
||||||
|
// smoke test of one already there (a canary, an update).
|
||||||
|
func (s *Server) embeds(name string) bool {
|
||||||
|
if s.deps.Current == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, m := range s.deps.Current() {
|
||||||
|
if m.Name == name {
|
||||||
|
return m.Capabilities.Embeddings
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
@@ -397,3 +397,18 @@ func TestOnlyTheLastFinishedJobsAreRemembered(t *testing.T) {
|
|||||||
t.Error("the oldest finished job is still remembered")
|
t.Error("the oldest finished job is still remembered")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestAnEmbeddingModelIsServedForEmbedding(t *testing.T) {
|
||||||
|
// Four slots and a 512-token batch: an import went at four passages a
|
||||||
|
// pass, and one passage of 516 tokens was refused.
|
||||||
|
args := strings.Join(llamaArgs(Request{Embeddings: true, ContextLength: embedSlotTokens,
|
||||||
|
CtxPool: embedSlotTokens * embedSlots}), " ")
|
||||||
|
for _, want := range []string{"--embedding", "--ctx-size 32768", "--parallel 32", "--batch-size 1024", "--ubatch-size 1024"} {
|
||||||
|
if !strings.Contains(args, want) {
|
||||||
|
t.Errorf("%q not in %q", want, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if args := strings.Join(llamaArgs(Request{ContextLength: 4096, CtxPool: 16384}), " "); strings.Contains(args, "embedding") {
|
||||||
|
t.Errorf("a chat model got %q", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -689,7 +689,7 @@ func (s *Server) replace(ctx context.Context, j *Job, name string) error {
|
|||||||
}
|
}
|
||||||
s.step(j, "replicas", Done, fmt.Sprintf("%d replaced", len(old)))
|
s.step(j, "replicas", Done, fmt.Sprintf("%d replaced", len(old)))
|
||||||
s.step(j, "smoke test", Running, "asking "+ready[0].ID+" to say hello")
|
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 {
|
if err != nil {
|
||||||
s.step(j, "smoke test", Failed, err.Error())
|
s.step(j, "smoke test", Failed, err.Error())
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
idle := now.Sub(rep.LastUsedAt)
|
idle := now.Sub(rep.LastUsedAt)
|
||||||
if idle < r.cfg.EvictionGrace {
|
if idle < r.evictionGrace() {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
// A replica that has only just started is left alone however idle it
|
// A replica that has only just started is left alone however idle it
|
||||||
@@ -128,7 +128,7 @@ func (r *Reconciler) evictable(wantedBy string) []evictionCandidate {
|
|||||||
// otherwise take turns evicting each other, paying a model load each
|
// otherwise take turns evicting each other, paying a model load each
|
||||||
// time — and a load is expensive enough that waiting is strictly
|
// time — and a load is expensive enough that waiting is strictly
|
||||||
// better than winning.
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
nodes := map[string]struct{}{}
|
nodes := map[string]struct{}{}
|
||||||
@@ -156,3 +156,34 @@ func (r *Reconciler) EvictionReason(model string) string {
|
|||||||
}
|
}
|
||||||
return "nothing idle enough to stop"
|
return "nothing idle enough to stop"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// evictionGrace is how long an on-demand replica must go unasked-for before
|
||||||
|
// another model may have its card: the cluster's setting, else the
|
||||||
|
// configuration's.
|
||||||
|
func (r *Reconciler) evictionGrace() time.Duration {
|
||||||
|
if d := time.Duration(r.evictGrace.Load()); d > 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return r.cfg.EvictionGrace
|
||||||
|
}
|
||||||
|
|
||||||
|
// minLifetime is how long a replica is safe from eviction once it is up.
|
||||||
|
func (r *Reconciler) minLifetime() time.Duration {
|
||||||
|
if d := time.Duration(r.minLife.Load()); d > 0 {
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
return r.cfg.MinLifetime
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetEvictionTimes puts the cluster's on_demand settings in force: how long
|
||||||
|
// an idle on-demand replica keeps its card, and how long a new one is safe.
|
||||||
|
// Zero keeps the configuration's.
|
||||||
|
//
|
||||||
|
// On one card shared by several models these are the wait between them: a
|
||||||
|
// 3D job after a picture waited out five minutes of a picture model it no
|
||||||
|
// longer needed.
|
||||||
|
func (r *Reconciler) SetEvictionTimes(afterIdle, minLifetime time.Duration) {
|
||||||
|
r.evictGrace.Store(int64(afterIdle))
|
||||||
|
r.minLife.Store(int64(minLifetime))
|
||||||
|
r.Notify()
|
||||||
|
}
|
||||||
@@ -205,9 +205,9 @@ func (r *Reconciler) WaitFor(model string) (heldBy string, eta time.Duration, ok
|
|||||||
// Evicted sooner, when it is no higher a class than the model
|
// Evicted sooner, when it is no higher a class than the model
|
||||||
// waiting; one put first is waited for to its idle timeout.
|
// waiting; one put first is waited for to its idle timeout.
|
||||||
if catalog.ClassRank(other.EffectiveClass()) <= catalog.ClassRank(m.EffectiveClass()) {
|
if catalog.ClassRank(other.EffectiveClass()) <= catalog.ClassRank(m.EffectiveClass()) {
|
||||||
evict := rep.LastUsedAt.Add(r.cfg.EvictionGrace)
|
evict := rep.LastUsedAt.Add(r.evictionGrace())
|
||||||
if !rep.ReadyAt.IsZero() && rep.ReadyAt.Add(r.cfg.MinLifetime).After(evict) {
|
if !rep.ReadyAt.IsZero() && rep.ReadyAt.Add(r.minLifetime()).After(evict) {
|
||||||
evict = rep.ReadyAt.Add(r.cfg.MinLifetime)
|
evict = rep.ReadyAt.Add(r.minLifetime())
|
||||||
}
|
}
|
||||||
if evict.Before(h.release) {
|
if evict.Before(h.release) {
|
||||||
h.release = evict
|
h.release = evict
|
||||||
|
|||||||
@@ -249,7 +249,7 @@ func (r *Reconciler) victims(m catalog.Model) []victim {
|
|||||||
aboveMin := i < above
|
aboveMin := i < above
|
||||||
tier := -1
|
tier := -1
|
||||||
switch {
|
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
|
tier = 0
|
||||||
case rank < myRank && aboveMin:
|
case rank < myRank && aboveMin:
|
||||||
tier = 2
|
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
|
// Just started: stopping it throws away a model load that has
|
||||||
// only now been paid for.
|
// 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
|
continue
|
||||||
}
|
}
|
||||||
if len(nodesOf(rep)) > 1 {
|
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.
|
// 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.
|
// A model nobody has wanted has no entry and is simply not running.
|
||||||
wanted map[string]time.Time
|
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
|
// 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.
|
// ready, for telling a request waiting on it about how long it waits.
|
||||||
coldStart map[string]time.Duration
|
coldStart map[string]time.Duration
|
||||||
|
|||||||
@@ -1763,6 +1763,31 @@ func mustResolve(t *testing.T, h *harness, name string) catalog.Model {
|
|||||||
return m
|
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) {
|
func TestAFreshlyStartedReplicaIsNotEvicted(t *testing.T) {
|
||||||
// What stops two models on a cluster with room for one from taking turns
|
// 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
|
// evicting each other, paying a model load each time. A load is expensive
|
||||||
|
|||||||
@@ -260,6 +260,10 @@ var Reference = []Block{
|
|||||||
{Key: "mirror", Type: "string", Doc: "Mirror is the mirror's file address, as nodes reach it:\nhttp://10.0.0.5:8081 — the port model files are served on, not the\nmanagement API. Set, the cluster is offline: nodes install engines\nfrom it and never from their publishers, releases come from it unless\nreleases names another place, and no registry is asked whether a\nmodel has newer weights. Python engines (vLLM, SGLang) come from PyPI,\nwhich no bundle carries; on an offline node they are installed by hand.", Nested: ""},
|
{Key: "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: ""},
|
{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{
|
{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: "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: ""},
|
{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: "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: "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: "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: "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: "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: ""},
|
{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/firewall.go",
|
||||||
"internal/config/serving.go",
|
"internal/config/serving.go",
|
||||||
"internal/config/presets.go",
|
"internal/config/presets.go",
|
||||||
|
"internal/config/ondemand.go",
|
||||||
"internal/config/agents.go",
|
"internal/config/agents.go",
|
||||||
"internal/mail/mail.go",
|
"internal/mail/mail.go",
|
||||||
"internal/notify/notify.go",
|
"internal/notify/notify.go",
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ type Header struct {
|
|||||||
// them; otherwise a head is EmbeddingLength / HeadCount.
|
// them; otherwise a head is EmbeddingLength / HeadCount.
|
||||||
KeyLength uint64
|
KeyLength uint64
|
||||||
ValueLength 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
|
// 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
|
h.KeyLength = num
|
||||||
case "attention.value_length":
|
case "attention.value_length":
|
||||||
h.ValueLength = num
|
h.ValueLength = num
|
||||||
|
case "pooling_type":
|
||||||
|
h.Embedding = num > 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The tokenizer's arrays — megabytes of vocabulary — come after the
|
// The tokenizer's arrays — megabytes of vocabulary — come after the
|
||||||
|
|||||||
@@ -163,3 +163,28 @@ func TestArraysNestedDeepAreRefused(t *testing.T) {
|
|||||||
t.Errorf("ten thousand nested arrays: %v", err)
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -258,7 +258,11 @@ type Result struct {
|
|||||||
|
|
||||||
// redact keeps a webhook's host and drops its path, where Slack and others
|
// redact keeps a webhook's host and drops its path, where Slack and others
|
||||||
// put the secret.
|
// 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 i := strings.Index(u, "://"); i >= 0 {
|
||||||
if j := strings.IndexByte(u[i+3:], '/'); j >= 0 {
|
if j := strings.IndexByte(u[i+3:], '/'); j >= 0 {
|
||||||
return u[:i+3+j] + "/…"
|
return u[:i+3+j] + "/…"
|
||||||
|
|||||||
@@ -360,3 +360,32 @@ func TestAStandbyLeavesJobsToTheLeader(t *testing.T) {
|
|||||||
t.Errorf("the leader did not serve it: %v", got)
|
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"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
"gitea.mynodes.xyz/ilker/llmgw/internal/auth"
|
||||||
@@ -27,6 +28,10 @@ const (
|
|||||||
jobCancelCheck = 2 * time.Second
|
jobCancelCheck = 2 * time.Second
|
||||||
// jobMaxResult bounds what one job may answer: a mesh, a short video.
|
// jobMaxResult bounds what one job may answer: a mesh, a short video.
|
||||||
jobMaxResult = 256 << 20
|
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
|
// 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(""))
|
owner := fmt.Sprintf("%s:%d:%s", host, os.Getpid(), newID(""))
|
||||||
t := time.NewTicker(jobPoll)
|
t := time.NewTicker(jobPoll)
|
||||||
defer t.Stop()
|
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 {
|
||||||
for r.leading() {
|
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()
|
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 err != nil {
|
||||||
if ctx.Err() == nil {
|
if ctx.Err() == nil {
|
||||||
r.log.Warn("could not look for jobs to serve", "err", err)
|
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 {
|
if !ok {
|
||||||
break
|
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
|
// A job's record outlives its file by nothing: both go at the
|
||||||
// retention.
|
// retention.
|
||||||
|
|||||||
@@ -12,9 +12,10 @@ import (
|
|||||||
// Anthropic's Messages API (/v1/messages), translated to a chat completion
|
// 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
|
// 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
|
// Code and Anthropic's SDKs reach the models here. Text, images, tools and
|
||||||
// their results, the system prompt and streaming are translated; extended
|
// their results, the system prompt, streaming and extended thinking are
|
||||||
// thinking, prompt caching and server tools are not, and are ignored rather
|
// translated; prompt caching and server tools are not, and are ignored rather
|
||||||
// than refused.
|
// 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 {
|
type msgRequest struct {
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
@@ -34,8 +35,16 @@ type msgRequest struct {
|
|||||||
Metadata *struct {
|
Metadata *struct {
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
} `json:"metadata"`
|
} `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 {
|
type msgMessage struct {
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content json.RawMessage `json:"content"`
|
Content json.RawMessage `json:"content"`
|
||||||
@@ -197,6 +206,16 @@ func messagesToChat(in msgRequest) (map[string]any, error) {
|
|||||||
if in.Metadata != nil && in.Metadata.UserID != "" {
|
if in.Metadata != nil && in.Metadata.UserID != "" {
|
||||||
out["user"] = 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
|
var tools []map[string]any
|
||||||
for _, t := range in.Tools {
|
for _, t := range in.Tools {
|
||||||
if t.Type != "" && t.Type != "custom" {
|
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.ContentLength = int64(len(upstream))
|
||||||
next.Header.Set("Content-Type", "application/json")
|
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)
|
r.handleInference("/v1/chat/completions")(t, next)
|
||||||
t.finish()
|
t.finish()
|
||||||
}
|
}
|
||||||
@@ -309,6 +328,11 @@ type msgTranslator struct {
|
|||||||
model string
|
model string
|
||||||
stream bool
|
stream bool
|
||||||
id string
|
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
|
status int
|
||||||
failed bool
|
failed bool
|
||||||
@@ -415,6 +439,20 @@ func stopReason(finish string) string {
|
|||||||
return "end_turn"
|
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() {
|
func (t *msgTranslator) closeText() {
|
||||||
if t.textOpen {
|
if t.textOpen {
|
||||||
t.event("content_block_stop", map[string]any{"index": t.textIndex})
|
t.event("content_block_stop", map[string]any{"index": t.textIndex})
|
||||||
@@ -433,8 +471,11 @@ func (t *msgTranslator) chatLine(line string) {
|
|||||||
var chunk struct {
|
var chunk struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Delta struct {
|
Delta struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ToolCalls []struct {
|
// The reasoning, by either name the engines give it.
|
||||||
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
|
ToolCalls []struct {
|
||||||
Index int `json:"index"`
|
Index int `json:"index"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Function struct {
|
Function struct {
|
||||||
@@ -461,7 +502,18 @@ func (t *msgTranslator) chatLine(line string) {
|
|||||||
if ch.FinishReason != "" {
|
if ch.FinishReason != "" {
|
||||||
t.stopReason = stopReason(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 != "" {
|
if d := ch.Delta.Content; d != "" {
|
||||||
|
t.closeThinking()
|
||||||
if !t.textOpen && t.text.Len() == 0 {
|
if !t.textOpen && t.text.Len() == 0 {
|
||||||
t.textIndex, t.textOpen = t.nextBlock, true
|
t.textIndex, t.textOpen = t.nextBlock, true
|
||||||
t.nextBlock++
|
t.nextBlock++
|
||||||
@@ -477,6 +529,7 @@ func (t *msgTranslator) chatLine(line string) {
|
|||||||
for _, tc := range ch.Delta.ToolCalls {
|
for _, tc := range ch.Delta.ToolCalls {
|
||||||
c := t.byIndex[tc.Index]
|
c := t.byIndex[tc.Index]
|
||||||
if c == nil {
|
if c == nil {
|
||||||
|
t.closeThinking()
|
||||||
t.closeText()
|
t.closeText()
|
||||||
for _, o := range t.calls {
|
for _, o := range t.calls {
|
||||||
if o.open {
|
if o.open {
|
||||||
@@ -532,6 +585,7 @@ func (t *msgTranslator) finish() {
|
|||||||
t.chatLine(rest)
|
t.chatLine(rest)
|
||||||
}
|
}
|
||||||
t.begin()
|
t.begin()
|
||||||
|
t.closeThinking()
|
||||||
t.closeText()
|
t.closeText()
|
||||||
for _, c := range t.calls {
|
for _, c := range t.calls {
|
||||||
if c.open {
|
if c.open {
|
||||||
@@ -553,8 +607,10 @@ func (t *msgTranslator) fromChatJSON(b []byte) {
|
|||||||
var c struct {
|
var c struct {
|
||||||
Choices []struct {
|
Choices []struct {
|
||||||
Message struct {
|
Message struct {
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ToolCalls []struct {
|
ReasoningContent string `json:"reasoning_content"`
|
||||||
|
Reasoning string `json:"reasoning"`
|
||||||
|
ToolCalls []struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Function struct {
|
Function struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -578,6 +634,9 @@ func (t *msgTranslator) fromChatJSON(b []byte) {
|
|||||||
if len(c.Choices) > 0 {
|
if len(c.Choices) > 0 {
|
||||||
ch := c.Choices[0]
|
ch := c.Choices[0]
|
||||||
reason = stopReason(ch.FinishReason)
|
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 != "" {
|
if ch.Message.Content != "" {
|
||||||
content = append(content, map[string]any{"type": "text", "text": 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())
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
-3
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strings"
|
||||||
"time"
|
"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
|
// 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.
|
// 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
|
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 = ?
|
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)
|
ORDER BY created_at LIMIT 1)
|
||||||
AND lease_until < ?
|
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) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return Job{}, false, nil
|
return Job{}, false, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -120,3 +120,24 @@ func TestAnOperatorSeesEveryOwnersJobs(t *testing.T) {
|
|||||||
t.Errorf("%d queued", len(queued))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -239,14 +239,14 @@ func TestThePageReloadsOnlyWhileSomethingIsDownloading(t *testing.T) {
|
|||||||
|
|
||||||
// A reload while nothing is happening would take with it whatever the
|
// A reload while nothing is happening would take with it whatever the
|
||||||
// operator was typing into the fetch form.
|
// 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")
|
t.Error("an idle page should not reload itself")
|
||||||
}
|
}
|
||||||
|
|
||||||
busy := mirrorGet(t, mirrorPage(t, &fakeMirror{models: []mirror.Status{
|
busy := mirrorGet(t, mirrorPage(t, &fakeMirror{models: []mirror.Status{
|
||||||
{Model: "m", Version: "v", File: "f", State: mirror.Fetching},
|
{Model: "m", Version: "v", File: "f", State: mirror.Fetching},
|
||||||
}}, true), "/admin/mirror").Body.String()
|
}}, 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")
|
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") {
|
if strings.Contains(held, `name="source" required`) || strings.Contains(held, "Search Hugging Face") {
|
||||||
t.Error("other tabs' forms are on the list")
|
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")
|
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") {
|
if reg.explored != 1 || !strings.Contains(explore, "Search Hugging Face") {
|
||||||
t.Error("the explorer tab did not explore")
|
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")
|
t.Error("the explorer shows the list, or reloads away a search")
|
||||||
}
|
}
|
||||||
// A link from before there were tabs still lands on the explorer.
|
// A link from before there were tabs still lands on the explorer.
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func TestAHeldModelOpensToWhereItRunsAndHowMuchRoomIsLeft(t *testing.T) {
|
|||||||
if strings.Contains(body, "secret-engine-key") {
|
if strings.Contains(body, "secret-engine-key") {
|
||||||
t.Error("the engine's key reached the page")
|
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")
|
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');
|
var msg = e.target.getAttribute && e.target.getAttribute('data-confirm');
|
||||||
if (msg && !window.confirm(msg)) { e.preventDefault(); }
|
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
|
// 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) {
|
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();
|
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>
|
{{define "admin_mirror"}}<!doctype html>
|
||||||
<html lang="{{lang}}"><head>
|
<html lang="{{lang}}"><head>
|
||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<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
|
{{/* Only while something is downloading, and not while a form is being
|
||||||
take with it whatever the operator was typing into the form below. */}}
|
filled in: pages.js holds the reload off then. */}}
|
||||||
{{if .RefreshSec}}<meta http-equiv="refresh" content="{{.RefreshSec}}">{{end}}
|
{{if .RefreshSec}}<meta name="llmgw-refresh" content="{{.RefreshSec}}">{{end}}
|
||||||
<script defer src="/_static/pages.js"></script>
|
<script defer src="{{asset "pages.js"}}"></script>
|
||||||
<title>{{t "mirror.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
<title>{{t "mirror.title"}} · llmgw</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||||
</head><body><div class="wrap">
|
</head><body><div class="wrap">
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<link rel="stylesheet" href="/_static/katex/katex.min.css">
|
<link rel="stylesheet" href="/_static/katex/katex.min.css">
|
||||||
<script defer src="/_static/katex/katex.min.js"></script>
|
<script defer src="/_static/katex/katex.min.js"></script>
|
||||||
<script defer src="/_static/katex/auto-render.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="csrf" content="{{.CSRF}}">
|
||||||
<meta name="chat-owner" content="{{.User.ID}}">
|
<meta name="chat-owner" content="{{.User.ID}}">
|
||||||
</head><body><div class="wrap">
|
</head><body><div class="wrap">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<noscript>{{.RefreshMeta}}</noscript>
|
<noscript>{{.RefreshMeta}}</noscript>
|
||||||
<meta name="htmx-config" content='{"allowEval":false}'>
|
<meta name="htmx-config" content='{"allowEval":false}'>
|
||||||
<script src="/_static/htmx-2.0.11.min.js"></script>
|
<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}}">
|
<title>llmgw — {{t "dash.title"}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||||
</head><body><div class="wrap">
|
</head><body><div class="wrap">
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<html lang="{{lang}}"><head>
|
<html lang="{{lang}}"><head>
|
||||||
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
<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}}">
|
<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}}">
|
<meta name="csrf" content="{{.CSRF}}">
|
||||||
</head><body><div class="wrap">
|
</head><body><div class="wrap">
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<noscript>{{.RefreshMeta}}</noscript>
|
<noscript>{{.RefreshMeta}}</noscript>
|
||||||
<meta name="htmx-config" content='{"allowEval":false}'>
|
<meta name="htmx-config" content='{"allowEval":false}'>
|
||||||
<script src="/_static/htmx-2.0.11.min.js"></script>
|
<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}}">
|
<title>llmgw — {{.Node.ID}}</title><link rel="stylesheet" href="{{stylesheet}}">
|
||||||
</head><body><div class="wrap">
|
</head><body><div class="wrap">
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
Reference in new issue
Block a user