viiwork 2 refuses a v1 viiwork.yaml at startup with
v1 config: see docs/migrating-to-v2.md. This page is where that points: what
changed, how several v1 instance files become one v2 file, and how to run and
roll back a converted machine.
1. What changes
- One node per machine. v1 ran one viiwork instance per model, each on its
own port. v2 runs one process per machine, and that process supervises every
model on it. Its
models:list replaces the instance files. - Two fixed ports, the same on every machine. The API and every dashboard
are on 8086, and membership gossip is on 7946 (tcp and udp). The
per-family port schemes and
server.mesh_portare gone. - A mesh that forms itself. Nodes find each other through tailscaled
(
mesh.network: tailnet, the default), mDNS on a LAN (mesh.network: lan), ormesh.seeds. You no longer writepeers.hostslists, and adding a machine means writing that machine's config and nothing anywhere else. A machine that stops is dropped from routing within seconds. - Explicit model names. v1 derived a model's id from its GGUF file name. v2
serves exactly
models[].name, which is what clients send. - Context is per slot. v1
model.context_sizewas llama.cpp's total, divided across slots. v2models[].contextis the context of one slot, for every engine. - Slot-aware routing. A request goes to a local backend with a free slot, else to the member with the most free slots, else it waits in a short queue on the node that received it.
- Aliases. A stable name such as
stable-codercan point at a real model mesh-wide and be switched once, from any machine, withviiwork alias set.
2. One file per machine
List every v1 instance file on the machine. Each one becomes one entry under
models: in a single v2 file:
model.pathbecomes the entry'spath, and you choose itsname;- the instance's GPUs become the entry's
gpus, as host GPU indices; - a tensor-split instance keeps its group size as
gpus_per_backend; - per-instance
server,peersandbalancersettings are dropped. The node has one API, one mesh membership and one router for all of its models.
Machine-wide sections (power, energy, cost, pipelines, activity,
health) appear once. When the v1 instances disagreed on one of them, pick the
value the machine should have. energy in particular was enabled on only one
instance per host in v1; in v2 there is only one node to enable it on.
A GPU index may appear in only one model, and the node refuses a file that lists one twice.
3. Key mapping
| v1 | v2 |
|---|---|
server.host, server.port | api.host, api.port (8086 on every machine) |
server.mesh_port | removed; /mesh is served on api.port |
server.cors.* | api.cors.* |
model.path | models[].path, one entry per v1 instance |
| model id from the file name | models[].name, explicit |
model.context_size | models[].context = context_size / parallel |
model.parallel | models[].parallel |
model.n_gpu_layers | removed; always full offload on GPU (add --n-gpu-layers to args to override) |
gpus.devices, or gpus.count with gpus.offset | models[].gpus, explicit host indices |
gpus.base_port | removed; backends take loopback ports |
gpus.power_limit_watts | gpu.power_limit_watts (AMD) |
gpus.tensor_split.enabled with group_size | models[].gpus_per_backend (group_size, or all GPUs when it was 0) |
gpus.tensor_split.mode, weights, main_gpu | llamacpp.split_mode, split_weights, main_gpu |
backend.binary, backend.threads | llamacpp.binary, llamacpp.threads |
backend.extra_args | models[].args |
health.interval, timeout, max_failures, respawn_grace | unchanged |
health.evict_on_hard_failure | removed; always on |
| (fixed at 3) | health.max_respawns |
activity.prompt_history | unchanged |
balancer.* | removed; routing by free slots |
peers.hosts, poll_interval, timeout | removed; discovery is automatic, mesh.seeds only where needed |
peers.gossip.* | removed; membership is always on |
peers.gossip.secret_env | mesh.secret_env; the value is now base64 of exactly 32 bytes (openssl rand -base64 32), or run mesh.open: true |
power, energy, cost, pipelines | unchanged |
--section.key value flags | removed; the config file is the only input |
llamacpp.* keys sit inside the model entry they apply to, for example
models[0].llamacpp.threads.
From viiwork-nvidia (vLLM)
A v1 vLLM node and a v2 node do not see each other. They are separate programs with separate protocols: v1 polled a written list of peers over HTTP, v2 gossips membership on 7946. A fleet converts together, or runs as two meshes until the last machine is across — the same rule as for llama.cpp nodes.
viiwork-nvidia.yaml (v1) | v2 |
|---|---|
server.host, server.port (9601) | api.host, api.port (8086 on every machine) |
server.mesh_port | removed; /mesh is served on api.port |
model.name, model.path | models[].name, models[].path, one entry per v1 instance |
gpus.devices | models[].gpus, same host indices |
gpus.tensor_parallel_size | models[].gpus_per_backend |
gpus.base_port | removed; backends take loopback ports |
vllm.binary | models[].vllm.binary |
vllm.max_model_len | models[].context — now required, and per slot |
vllm.max_num_seqs | models[].parallel |
vllm.gpu_memory_utilization | models[].vllm.gpu_memory_utilization |
vllm.startup_timeout | models[].startup_timeout |
vllm.shutdown_grace | health.respawn_grace, node-wide |
vllm.extra_args | models[].args |
peers.*, balancer.* | removed; gossip membership and routing by free slots |
Two of those are not renames and will bite:
max_model_lenwas optional and is now required. v1 let you omit it and take the checkpoint's own limit. v2'scontextis mandatory and is per slot, and it is what the node publishes to the mesh. A backend serving more than the node advertises makes ctx a lie on every dashboard, so the flag is always generated from this number.tensor_parallel_sizebecomesgpus_per_backend, and the arithmetic is the same:gpus.devices: [0,1,2,3]withtensor_parallel_size: 2becomesgpus: [0,1,2,3]withgpus_per_backend: 2— two backends of two cards.
From viiwork-freetoken
viiwork-freetoken.yaml (v1) | v2 |
|---|---|
server.host, server.port (9801) | api.host, api.port (8086) |
server.mesh_port | removed; /mesh is served on api.port |
model.name, model.path | models[].name, models[].path |
gpus.devices | models[].gpus, with gpus_per_backend: 1 |
gpus.base_port | removed; backends take loopback ports |
freetoken.binary | models[].freetoken.binary |
freetoken.max_seq_len | models[].context — now required, and per slot |
freetoken.max_running_requests | models[].parallel |
freetoken.memory_ratio | models[].freetoken.memory_ratio |
freetoken.moe_backend | models[].freetoken.moe_backend |
freetoken.startup_timeout | models[].startup_timeout |
freetoken.shutdown_grace | health.respawn_grace, node-wide |
freetoken.extra_args | models[].args |
peers.*, balancer.* | removed; gossip membership and routing by free slots |
One card per process, which v2 states rather than implies. The engine
rejects more than one card, so gpus_per_backend must be 1 — gpus: [0,1,2]
with gpus_per_backend: 1 is three single-card backends of one model, and
anything else is refused at startup by name. In v1 this was a property of how
you happened to write devices; in v2 it is validated.
The systemd unit is part of the config, and the table above does not cover it
A v1 instance's behaviour was not all in its YAML. The units also carried
Environment= lines, and those are not config keys, so nothing in the mapping
tables points at them. Read the unit alongside the file:
systemctl cat viiwork-<instance>.service | grep Environment=
v1 unit Environment= | v2 |
|---|---|
LD_LIBRARY_PATH=... | models[].env, and often required |
CUDA_VISIBLE_DEVICES, CUDA_DEVICE_ORDER | do not carry. viiwork sets both itself from models[].gpus; a hand-set value fights the node's own pinning |
HSA_OVERRIDE_GFX_VERSION (ROCm hosts) | models[].env, or the image already sets it |
HF_TOKEN and similar | models[].env |
LD_LIBRARY_PATH is the one that bites. A llama-server built in place rather
than installed as a package keeps its shared objects beside the binary, and
without the variable the backend dies the moment it execs:
llama-server: error while loading shared libraries:
libllama-server-impl.so: cannot open shared object file
The node reports that as a backend that will not start, which reads as a broken
build rather than a missing variable. viiwork-accept config cannot catch it
either — the binary and the weights both exist, and the failure only appears at
exec.
What one process now means for both
v1 ran one process per model: three models on a machine meant three config
files, three ports and three units. v2 runs one process per machine serving
every model on it, so those three files become three entries under models: in
one file, and the machine has one API port and one unit. The conversion is
mechanical, and viiwork-accept config will check the result before v1 stops:
viiwork-accept config --file /etc/viiwork/viiwork.yaml
4. A worked example
A machine ran two v1 instances. The first served a 27B model on a pair of cards:
# viiwork.qwen38.yaml (v1)
server:
port: 9101
model:
path: /models/Qwen3.8-27B-Q6_K.gguf
context_size: 98304
parallel: 2
gpus:
devices: [0, 1]
base_port: 9001
tensor_split:
enabled: true
group_size: 2
backend:
extra_args: ["--spec-type", "draft-mtp", "--spec-draft-n-max", "1"]
peers:
hosts: ["127.0.0.1:9102"]
The second served an 8B model on one card:
# viiwork.granite.yaml (v1)
server:
port: 9102
model:
path: /models/granite-4.2-8b-Q4_K_M.gguf
context_size: 32768
parallel: 2
gpus:
devices: [2]
base_port: 9011
peers:
hosts: ["127.0.0.1:9101"]
Both become one v2 file:
# viiwork.yaml (v2)
node:
name: node-a
state_dir: /var/lib/viiwork
api:
port: 8086
mesh:
network: tailnet
models:
- name: Qwen3.8-27B
engine: llamacpp
path: /models/Qwen3.8-27B-Q6_K.gguf
gpus: [0, 1]
gpus_per_backend: 2 # group_size 2: one backend across both cards
context: 49152 # 98304 total / 2 slots
parallel: 2
startup_timeout: 45m # split across two cards: allow a long load (section 9)
args: ["--spec-type", "draft-mtp", "--spec-draft-n-max", "1"]
- name: granite-4.2-8b
engine: llamacpp
path: /models/granite-4.2-8b-Q4_K_M.gguf
gpus: [2]
context: 16384 # 32768 total / 2 slots
parallel: 2
The context arithmetic is the one change that silently matters. Copying
context_size: 98304 into context would ask llama.cpp for 196,608 tokens
(context × parallel) and very likely fail to fit in VRAM.
The v1 model ids were Qwen3.8-27B-Q6_K and granite-4.2-8b-Q4_K_M, taken from
the file names. Under v2 clients send Qwen3.8-27B and granite-4.2-8b, or an
alias (section 10).
5. Mesh mode
A mesh is either secured or open, and every member must be the same.
- Secured: set
VIIWORK_MESH_SECRETto the base64 of exactly 32 bytes (openssl rand -base64 32), the same on every machine. Gossip is encrypted and authenticated, a node without the secret cannot join, forwards between nodes are signed, andviiwork aliaswrites must carry a signature. - Open: leave the secret unset and set
mesh.open: true. Gossip is plaintext and any viiwork node that can reach port 7946 joins. On a tailnet that boundary is the tailnet itself, and transit is already encrypted. On a LAN it is everyone on the LAN, so the node logs a warning. Alias writes are accepted only from the node's own machine.
The node refuses to start with neither a secret nor mesh.open: true, and with
both. The error names the environment variable it looked for, so a missing or
mistyped variable fails loudly instead of quietly running a node unsecured.
A node in the wrong mode cannot gossip with the mesh. It logs
mesh mode mismatch with the peer's address and never appears as a member.
Preflight. Machines must reach each other on 7946 tcp and udp, and on 8086 tcp. On a tailnet, check the ACLs allow all three before converting the first machine.
Adding a secret to a running open mesh, without downtime. Do one step on every machine before starting the next:
- Remove
mesh.open: true, set the secret, and setmesh.secret_enforce: none. - Set
mesh.secret_enforce: outgoing. - Set
mesh.secret_enforce: full(the default).
Members stay alive throughout. To rotate a secret later, set the new one with
the old one in VIIWORK_MESH_SECRET_PREV everywhere, then remove the old one.
6. Running it
Docker
configs/docker-compose.v2.example.yaml
runs one node per machine. What it needs, and why:
network_mode: host. Gossip binds the machine's tailnet or LAN address, not0.0.0.0, and members dial each other's real addresses. A bridged container has neither.pid: host. Within a minute of a backend turning healthy, the node checks that the backend's processes really run on their assigned cards.rocm-smiandnvidia-smireport host PIDs, which a container with its own PID namespace cannot match. Withoutpid: hostthe check reports "unknown" and never kills anything, so mispinned backends go unnoticed.ipc: host. A second ROCm container on the same machine fails to initialise its GPUs without it: a v1 instance still running during a conversion, or a benchmark.- A state directory, bind-mounted at
/var/lib/viiwork. The alias table lives there. It must survive recreating the container, or the machine starts with an empty table until its first sync with the mesh. It is a host directory rather than a named volume, becausedocker volume prunedeletes a named volume whenever its container is stopped. - tailscaled's socket directory, read-only. On a tailnet the node learns its own address and finds other machines through tailscaled's local API. The directory is mounted rather than the socket file: a mounted file keeps its old inode when tailscaled restarts, which would leave the node without the local API until the container is recreated.
/etc/viiwork/mesh.env, a secured mesh's secret (section 7, step 5). The file is optional, so an open mesh runs without one. It can also carryENTSOE_API_KEYfor cost tracking.stop_grace_period: 90s. A clean stop leaves the mesh, lets in-flight requests finish forhealth.respawn_grace(60 s by default) and then stops the backends. Docker's default of 10 s would kill the node mid-drain.
Reload the model list after editing viiwork.yaml:
docker kill -s HUP viiwork
systemd
[Unit]
Description=viiwork node
After=network-online.target tailscaled.service
Wants=network-online.target
[Service]
ExecStart=/usr/local/bin/viiwork --config /etc/viiwork/viiwork.yaml
ExecReload=/bin/kill -HUP $MAINPID
EnvironmentFile=-/etc/viiwork/mesh.env
Restart=on-failure
TimeoutStopSec=90
[Install]
WantedBy=multi-user.target
/etc/viiwork/mesh.env holds VIIWORK_MESH_SECRET=... (section 7, step 5) and
can also hold ENTSOE_API_KEY for cost tracking. systemctl reload viiwork
applies a changed model list.
What a reload does
Reload re-reads the file and validates it first; an invalid file is logged and the running configuration kept. Models are compared by name: an added model starts, a removed one stops admitting requests and drains, and a changed entry restarts that model only. Changes to any other section are logged as needing a restart and are not applied.
7. Converting a host
Convert one machine at a time. The procedure proves the new configuration before
the machine's v1 instances stop, and records the numbers the mesh actually
achieves. Every check is a viiwork-accept command (section 8), which only reads
node state and sends inference requests.
The examples convert node-a, and node-b is a machine already running v2.
-
Inventory. For every v1 instance on the machine, record its config file, GPUs, tensor-split layout (
group_size),context_sizeandparallel,extra_args, and itspower,energyandcostsettings. -
Write one v2 file with the key table (section 3) and the worked example (section 4). Set
node.state_dirto a host directory, such as/var/lib/viiwork, and on Docker bind-mount it as the compose example does. A named volume is riskier:docker volume prunedeletes it while the node is stopped, and the machine's alias table with it. -
Validate the file on the machine, while v1 still runs:
viiwork-accept config --file viiwork.yaml --dummy-secret --models-root /srv/models--dummy-secretstands in a random key for the mesh secret, so validation needs no secret yet.--models-rootmaps the file's container paths under/models/to the host directory mounted there; leave it out when the paths are host paths. Add--require-mesh secured(oropen) once the mesh's mode is decided. Every check must pass before going on: an invalid file found after v1 has stopped is an outage. -
Check the ports. Gossip needs 7946 over tcp and udp between machines, and the API needs 8086. On this machine, echo on its mesh address (its tailnet IP, or its LAN address in a LAN mesh):
viiwork-accept ports serve --addr 192.0.2.10and from another machine in the mesh:
viiwork-accept ports probe --host node-aapi tcppasses when anything listens on 8086, which on a v1 machine is usually an instance'smesh_port. When nothing does yet, repeat the probe after step 8. -
Install the secret (secured mesh only). Generate it once for the whole mesh, on one machine, into a file readable by root and the operators' group:
OPS_GROUP=operators # the group your operators share sudo install -d -m 0755 /etc/viiwork sudo install -m 0640 -g "$OPS_GROUP" /dev/null /etc/viiwork/mesh.env echo "VIIWORK_MESH_SECRET=$(openssl rand -base64 32)" | sudo tee /etc/viiwork/mesh.env >/dev/nullCopy the file to every other machine the same way, without printing it, and keep its mode
0640. The group matters:docker composereads the file as the user running it, andviiwork aliasneeds the secret in the operator's environment. Never commit the secret, and never pass it on a command line, where it lands in shell history and process listings. Check each copy by its decoded length, which must print32:sh -c '. /etc/viiwork/mesh.env && printf %s "$VIIWORK_MESH_SECRET" | base64 -d | wc -c' -
Stop every v1 instance on the machine. They hold the GPUs the node is about to use. Until step 8, the machine's models are served by the rest of the mesh or not at all.
-
Start the node and time its join. Start the timer first, on another node's view of the mesh:
viiwork-accept join --observer node-b:8086 --node node-a --expect Qwen3.8-27B,granite-4.2-8bthen start the node (
docker compose up -d, orsystemctl start viiwork). On the first machine of a new mesh there is no other node yet, so the observer is the node itself (--observer node-a:8086). -
Wait for the models to load, with a timeout of the largest
startup_timeoutin the file plus a margin:viiwork-accept ready --node node-a:8086 --timeout 50m -
Check inference, through another node as well as directly:
viiwork-accept models --node node-a:8086 --via node-b:8086and then saturate each model in turn:
viiwork-accept saturate --node node-a:8086 --model Qwen3.8-27B -
Time departures, from another node. Start the timer, then hard-stop the node (
docker kill viiwork):viiwork-accept gone --observer node-b:8086 --node node-a --expect-state deadStart the node again and wait for
ready. Then time a graceful stop (docker stop viiwork) the same way, and start the node again afterwards:viiwork-accept gone --observer node-b:8086 --node node-a --expect-state leftThe first machine of a new mesh has no other node to observe from; time its departures once a second machine is converted.
-
Point clients at
http://node-a:8086. Every request names its model, and aliases (section 10) give clients stable names.
Rollback, at any point after step 6: stop the v2 node, start the v1 instances, and point clients back (section 11).
8. Acceptance
viiwork-accept is the checking tool the conversion uses. It only reads node
status and sends inference requests; it never starts, stops or configures
anything, so it is safe to run against a live mesh. It is one static binary that
runs from any machine:
CGO_ENABLED=0 go build -o viiwork-accept ./cmd/viiwork-accept
Each command prints one line per check (PASS or FAIL, the check, how long it
took, and a detail) and a tally. Nodes are given as host:port.
ports serve
Echoes on a port over tcp and udp until stopped, so that ports probe on another
machine can prove both protocols get through. A port it cannot bind fails at
once, which is how "something already holds 7946" shows. Every connection and
datagram is logged with its sender.
| Flag | Default | Meaning |
|---|---|---|
--addr | required | this machine's mesh address |
--port | 7946 | the port to echo on, tcp and udp |
--for | 10m | stop after this long; Ctrl-C also stops it |
It passes when it listened until stopped:
viiwork-accept ports serve --addr 192.0.2.10 --for 30m
echoing on tcp and udp 192.0.2.10:7946
tcp 192.0.2.11:50814: echoed 32 bytes
udp 192.0.2.11:41377: echoed 32 bytes
PASS echo tcp+udp 192.0.2.10:7946 1m12.4s stopped
ports serve 192.0.2.10:7946: 1/1 passed
ports probe
Checks, from the machine it runs on, api tcp (a TCP connect to the API port),
gossip tcp and gossip udp (a nonce sent to ports serve comes back
unchanged; udp tries three times, a second apart).
| Flag | Default | Meaning |
|---|---|---|
--host | required | the machine to probe |
--api-port | 8086 | checked by TCP connect |
--gossip-port | 7946 | checked by tcp and udp echo |
--timeout | 5s | limit per check |
Passing means firewalls and tailnet ACLs let all three through:
viiwork-accept ports probe --host node-a --timeout 5s
PASS api tcp node-a:8086 0s
PASS gossip tcp node-a:7946 0s
FAIL gossip udp node-a:7946 5s no reply
ports probe node-a: 2/3 passed
A udp failure like this one otherwise shows up only later, as membership that flaps.
config
Checks config valid (the node's own validation, the mesh secret included),
mesh secured or mesh open, api port 8086 and gossip port 7946, then per
model:
weights <model>: the weights exist (every shard of a split GGUF, every file of a model directory);startup_timeout <model>: a model split across GPUs, or with more than 20 GiB of weights, has astartup_timeoutof at least 45 minutes (section 9).
After the checks it prints a summary of what the node will run.
| Flag | Default | Meaning |
|---|---|---|
--file | required | the v2 viiwork.yaml |
--models-root | none | host directory that /models/ paths refer to |
--dummy-secret | off | a random key for the file's secret variable when it is unset; never printed, and not used for an open mesh |
--require-mesh | none | fail unless the mode is secured or open |
Passing means the node will accept the file and find its weights. Without the
startup_timeout: 45m of the worked example, the split model fails:
viiwork-accept config --file viiwork.yaml --models-root /srv/models --dummy-secret
PASS config valid
PASS mesh secured
PASS api port 8086
PASS gossip port 7946
PASS weights Qwen3.8-27B /srv/models/Qwen3.8-27B-Q6_K.gguf 22.1 GiB
FAIL startup_timeout Qwen3.8-27B gpus_per_backend 2, weights 22.1 GiB: need at least 45m0s, have engine default
PASS weights granite-4.2-8b /srv/models/granite-4.2-8b-Q4_K_M.gguf 4.6 GiB
PASS startup_timeout granite-4.2-8b engine default
config viiwork.yaml: 7/8 passed
node node-a state_dir /var/lib/viiwork network tailnet mesh secured api 8086 gossip 7946
MODEL ENGINE GPUS BACKENDS SLOTS CTX/SLOT STARTUP WEIGHTS
Qwen3.8-27B llamacpp 0,1 1 2 49152 default 22.1 GiB
granite-4.2-8b llamacpp 2 1 2 16384 default 4.6 GiB
join
Checks joined <node>: the observer's /v1/cluster lists the node alive, with
a status that names every expected model. The time counts from starting the
command. A node's status reaches other nodes on their 5-second status poll, so
expect a few seconds on top of the join itself.
| Flag | Default | Meaning |
|---|---|---|
--observer | required | a node already in the mesh |
--node | required | the name of the node expected to join |
--expect | none | comma-separated models the node must list |
--timeout | 15s | give up after this long |
--poll | 250ms | time between polls |
viiwork-accept join --observer node-b:8086 --node node-a --expect Qwen3.8-27B,granite-4.2-8b
PASS joined node-a 6.3s models Qwen3.8-27B, granite-4.2-8b
join node-a: 1/1 passed
On a timeout the detail says what was seen last, such as
alive, missing granite-4.2-8b, not a member, or last error: ... when the
observer did not answer.
ready
Checks ready <model> for every model the node lists, timed to the first
healthy backend of each, and finally all backends healthy.
| Flag | Default | Meaning |
|---|---|---|
--node | required | the node's API |
--timeout | 60m | give up after this long |
--poll | 2s | time between polls |
viiwork-accept ready --node node-a:8086 --timeout 50m
PASS ready Qwen3.8-27B 3m14.2s
PASS ready granite-4.2-8b 4m1.9s
PASS all backends healthy
ready node-a:8086: 3/3 passed
A model that does not load in time fails with each backend's state and phase,
such as Qwen3.8-27B/0 starting (loading).
gone
Checks gone <node>: the observer no longer lists the node as alive. With
--expect-state left or dead it also checks state <state>, the state the
node settles in. A gracefully stopped node turns left at once. A hard-stopped
one stays alive while the other members suspect it, typically several seconds,
and then turns dead; requests stop going to it sooner, once its capacity
reports are 3 s old. Times count from starting the command, so stop the node
right after starting it.
| Flag | Default | Meaning |
|---|---|---|
--observer | required | a node that stays in the mesh |
--node | required | the name of the node expected to go |
--expect-state | any | any, left or dead |
--timeout | 120s | give up after this long |
--poll | 250ms | time between polls |
viiwork-accept gone --observer node-b:8086 --node node-a --expect-state dead
PASS gone node-a 11.6s dead
PASS state dead 11.6s dead
gone node-a: 2/2 passed
models
Per model, checks:
content <model>: a real chat completion with thinking off, pinned to the node with?host=, returns non-empty content, andX-Viiwork-Nodenames the node;tools <model>: a request offering aget_weathertool returnsfinish_reason: tool_callswith aget_weathercall;pin <model>, with--via: the content request sent to another node and pinned to this one comes back from this node (X-Viiwork-Node) by way of the other (X-Viiwork-Origin).
| Flag | Default | Meaning |
|---|---|---|
--node | required | the node's API |
--model | every model the node lists | a model to check; repeatable |
--via | none | another node's API for the pin check |
--timeout | 10m | limit per request |
viiwork-accept models --node node-a:8086 --via node-b:8086
PASS content Qwen3.8-27B 1.9s ready
PASS tools Qwen3.8-27B 3.4s finish_reason tool_calls, 1 tool call
PASS pin Qwen3.8-27B 1.2s node-a via node-b
PASS content granite-4.2-8b 400ms ready
PASS tools granite-4.2-8b 900ms finish_reason tool_calls, 1 tool call
PASS pin granite-4.2-8b 300ms node-a via node-b
models node-a:8086: 6/6 passed
The llamacpp engine passes no --jinja: current llama.cpp builds enable Jinja
chat templates by default, and tool calls depend on them. On an older build
whose tools check fails with finish_reason stop, add --jinja to that
model's args.
saturate
Sends a model more concurrent requests than the node has healthy slots for it
(slots plus --extra), without a pin, and checks:
no 429 with free slots: no request was refused while the mesh had at least as many free slots as requests, and every answer was 200 or 429;spilled to a peer: when another member serves the model and the mesh has more free slots than the node, at least one answer came from another node (otherwisenot applicable);distribution: always passes, and counts the answers per node and how many waited in the queue.
| Flag | Default | Meaning |
|---|---|---|
--node | required | the node's API |
--model | required | the model to saturate |
--extra | 4 | requests beyond the node's slots |
--max-tokens | 200 | max_tokens per request |
--timeout | 10m | limit per request |
Free slots are read once before sending, so saturate a model that is otherwise quiet:
viiwork-accept saturate --node node-a:8086 --model Qwen3.8-27B
PASS no 429 with free slots n=6 200:6 meshFree=6
PASS spilled to a peer other members: node-b
PASS distribution node-a:2 node-b:4 queued:1
saturate node-a:8086: 3/3 passed
alias
Sends the content request with model set to the alias through every entry
point, without a pin, and checks alias <alias> via <entry>: the answer is 200,
X-Viiwork-Alias names the alias, and both X-Viiwork-Model and the response's
model name the expected model. An entry point is a node's host:port or a
gateway URL.
| Flag | Default | Meaning |
|---|---|---|
--entry | required | an entry point; repeatable |
--alias | required | the alias to request |
--expect-model | required | the model it must resolve to |
--bearer-env | none | variable holding a bearer token for the entry points |
--timeout | 10m | limit per request |
Aliases are accepted once for the whole mesh: set one, check it through two nodes, move it and check again, then revert it and check that every node follows:
viiwork alias set stable-coder Qwen3.8-27B
viiwork-accept alias --entry node-a:8086 --entry node-b:8086 --alias stable-coder --expect-model Qwen3.8-27B
viiwork alias set stable-coder granite-4.2-8b
viiwork-accept alias --entry node-a:8086 --entry node-b:8086 --alias stable-coder --expect-model granite-4.2-8b
viiwork alias revert stable-coder
viiwork-accept alias --entry node-a:8086 --entry node-b:8086 --alias stable-coder --expect-model Qwen3.8-27B
PASS alias stable-coder via node-a:8086 1.8s Qwen3.8-27B on node-a
PASS alias stable-coder via node-b:8086 2.1s Qwen3.8-27B on node-a
alias stable-coder: 2/2 passed
Run viiwork alias where the node's config and, in a secured mesh, its secret
are available: inside the container (docker exec viiwork viiwork alias ls), or
in a shell that has loaded the secret (set -a; . /etc/viiwork/mesh.env; set +a).
An entry point that needs a key, such as a gateway, takes it from the
environment:
viiwork-accept alias --entry https://gateway.example.com --alias stable-coder --expect-model Qwen3.8-27B --bearer-env GATEWAY_KEY
Output and exit codes
Every command takes --json, which writes the report as JSON instead of text
(for config, with the summary under summary; the progress lines of
ports serve then go to stderr). The exit code is 0 when every check passed, 1
when any failed, and 2 for a usage error. Ctrl-C ends a waiting command, and its
open check fails as interrupted. The token named by --bearer-env is sent as
Authorization: Bearer <token> and never printed; reading it from the
environment keeps it off the command line. Each command's --help lists its
flags with their defaults.
9. Large models
A backend has startup_timeout to become healthy, and the llama.cpp default is
10 minutes. v1 allowed a model 30-45 minutes to load. A large GGUF on slow
storage needs longer, or the node gives up on it mid-load:
models:
- name: gpt-oss-120b
startup_timeout: 45m
viiwork-accept config fails a model split across GPUs, or with more than
20 GiB of weights, whose startup_timeout is below 45 minutes.
10. Clients
-
One URL per machine:
http://<machine>:8086. Every node routes every model in the mesh, so any machine works as an entry point. -
Name the model in every request. Model names are the
names in the configs, listed onGET /v1/modelsof any node. -
Use aliases as stable names. Point a client at an alias instead of a model, and switch the model without touching the client:
viiwork alias set stable-coder Qwen3.8-27B --fallback granite-4.2-8b viiwork alias ls viiwork alias revert stable-coderResponses to an aliased request carry
X-Viiwork-AliasandX-Viiwork-Model, naming the alias and the model that answered. -
Pin a machine with
?host=<node name>on the request, as in v1.8, now compared against node names.
11. Rollback
Keep the v1 image, compose files and instance configs until the machine has run v2 for a while. To roll a machine back:
- Stop the v2 node (
docker compose down, orsystemctl stop viiwork). - Start the v1 instances as before.
- If its clients were already moved in step 11 of the conversion (section 7), point them back at the v1 ports and model ids.
v1 and v2 are separate meshes and do not route to each other, so a mixed fleet needs clients to know which machine runs which during the transition.