AI — Complete Reference
The AI fleet routes work to a set of GPU hosts. You ask for an artifact and declare what the work is for; the broker decides where it runs, loads it if needed, and hands back a URL to call. This document covers every supported feature and configuration.
Overview
Three moving parts:
- Broker — the control plane. Owns scheduling, the catalog, and a data-plane proxy.
Reachable on the LAN at
http://192.168.1.13:8080. - Agents — one per GPU host. Poll desired state, launch/stop services, report health +
nvidia-smi. - Client —
ai-fleet-client, a pip package wrapping the broker API.
The unit of work is a ticket. You POST a request, the broker queues it, schedules a host,
loads the model if it isn't running, and marks the ticket ready with a
proxy URL. All your data-plane calls go through that URL — the real backend address stays hidden.
Quick start
import ai_fleet_client as sb, httpx
url = sb.request("qwen3.6-35b-a3b-heretic-v2-q4",
environment="production", urgency="interactive")
r = httpx.post(f"{url}/v1/chat/completions", json={
"messages": [{"role": "user", "content": "Hello"}]})
print(r.json()["choices"][0]["message"]["content"])
request() calls for models that can't co-reside will evict each other
every turn — the most common cause of timeouts on this fleet.Install & configuration
pip install ai-fleet-client --extra-index-url https://ai.wtray.com/wheels/
--extra-index-url, not --index-url.
--index-url replaces PyPI, and this wheelhouse only carries
ai-fleet-client — so pip cannot resolve its dependencies
(httpx, websockets) and fails with
ResolutionImpossible on a clean environment.Environment variables read by the client:
| Variable | Default | Purpose |
|---|---|---|
AI_BROKER | http://192.168.1.13:8080 | Broker base URL |
AI_STT_ARTIFACT | faster-whisper | Artifact the speech helpers resolve to |
AI_TTS_ARTIFACT | kokoro | Artifact the TTS helpers resolve to |
426 Upgrade Required
to older clients. Current line: 3.0.0.Artifacts
An artifact is the concrete thing you ask for — a model id with its quantisation
(qwen3-vl-32b-q8 and a Q4 of the same model are different artifacts), a ComfyUI
workflow, or a service name (kokoro, faster-whisper, pyannote).
A run-profile is a tested way to run one artifact on a specific host/GPU with specific
settings. One artifact has many profiles, and profiles of the same artifact can differ —
notably in context length. Enumerate the catalog at runtime with sb.fleet() rather than
hard-coding.
-q4 will never
give you -q8, even if the stronger one is loaded and free. Artifact choice is the caller's.Workload class
Every request and session declares three independent axes. They drive scheduling, not
model selection. environment and urgency are mandatory.
| Axis | Values (rank) | Meaning |
|---|---|---|
environmentrequired |
production (1) · development (0) |
Dominates scheduling. Any production work outranks all development work. |
urgencyrequired |
realtime (3) · interactive (2) · batch (1) · background (0) |
Breaks ties within an environment. Preemption order only. |
keep_alivedefault 30s |
seconds | Warm-linger after the call if nothing else needs the model. Independent of urgency. |
Rank is lexicographic: rank = environment×100 + urgency. A running service is
committed at the highest rank actively using it and can only be evicted by a strictly higher
rank — or unloaded once nothing needs it.
Requirements
Satisfy-or-queue hints. The broker only considers profiles that meet them, and returns
400 if no profile anywhere can. Unknown keys are ignored (forward-compatible).
| Key | Type | Effect |
|---|---|---|
min_context | int | Profile's tested context must be ≥ this. |
min_vram_mib | int | Summed VRAM across the profile's GPUs must be ≥ this. |
needs_vision | bool | Model must be a vision model. |
gpu | str | Testing only, gated. Pin to one exact card as "<host>:<placement>", e.g. "ai-9:gpu1". Requires bench_code. Malformed values (a bare host or bare index) return 400. See Benchmarking exact hardware. |
bench_code | str | Operator-issued code that unlocks gpu and simulate_load. Absent or invalid ⇒ 403. |
simulate_load | dict | Testing only, gated. {"ai-150": 5} — pretend a host carries this much extra in-flight work, to see where placement would land under load. |
The same artifact can offer different context windows
An artifact id names a model and a quantisation — not a guaranteed context length. The same id can be tested at different windows on different hardware, because a smaller card cannot hold a larger KV cache. Today:
| Artifact | Context | Where |
|---|---|---|
qwen3-4b-q4 | 16,384 | ai-150 ×2, ai-151 ×2, ai-9/gpu0 |
qwen3-4b-q4 | 4,096 | ai-9/gpu1 — the GTX 970, whose 3,500 MiB cannot hold more |
Two ways to deal with it, and you should use one of them if window size matters:
# 1. Demand a floor — profiles below it are filtered out entirely
url = sb.request("qwen3-4b-q4", requirements={"min_context": 16384})
# 2. Or observe what you actually got
url = sb.request("qwen3-4b-q4")
print(sb.last_placement)
# {'host': 'ai-9', 'service': 'qwen3-4b-q4__gpu1', 'context': 4096, ...}
Ready tickets carry host, service and context, and
ai-fleet-client records the last one in
sb.last_placement and logs it. Everything else about a shared artifact id
is held identical across hosts — same weights (verified by file size), same
sampling flags, same chat template. Differences that remain are performance-only
(--no-mmap on ai-150 vs mmap on ai-151, which changes paging, not output).
url = sb.request("darkidol-qwen3.8-27b-q4", environment="production", urgency="interactive",
requirements={"min_context": 32768})
min_context whenever the window matters. It both routes you to a
capable profile and fails loudly instead of silently giving you a smaller window.Placement
Constrain how a profile is laid out on the host. Pass placement= (and/or
gpu= for a specific index).
| Token | Matches |
|---|---|
None | Any placement (default). |
"single" | Variant occupies exactly one GPU. |
"all" | Variant spans more than one GPU (tensor-split). |
"gpu0", "gpu1", … | Exactly that GPU index. |
"cpu" | No GPU at all; uses system RAM. |
"mixed" | Uses GPU(s) and meaningful system RAM (e.g. MoE offload). |
| custom tag | Exact match against the manifest's placement tag. |
gpu=1 is equivalent to placement="gpu1" and matches only single-GPU variants
on that index.
"cpu" can free an entire GPU for a large one — see Co-residency.Sessions
A session declares the full set of artifacts a feature will use, so the broker can plan placement across the whole fleet at once, pre-warm them, and hold them for the session's lifetime — instead of scheduling each call blind to what comes next.
REQS = [
{"artifact":"qwen3.6-35b-a3b-heretic-v2-q4", "environment":"production","urgency":"interactive","keep_alive":120},
{"artifact":"darkidol-qwen3.8-27b-q4", "environment":"production","urgency":"interactive","keep_alive":120},
{"artifact":"qwen3-embedding-4b-q8", "environment":"production","urgency":"interactive","keep_alive":120,
"placement":"cpu"}, # frees a GPU for the VLM
]
with sb.session(REQS, priority=5.0) as s:
text = s.request("qwen3.6-35b-a3b-heretic-v2-q4")
vlm = s.request("darkidol-qwen3.8-27b-q4")
embed = s.request("qwen3-embedding-4b-q8", placement="cpu")
# run the whole loop in here — all three stay warm
Each entry accepts artifact, environment, urgency,
keep_alive (all required per entry), plus optional requirements,
placement, gpu_index. requires may also be a plain list of
artifact-id strings.
s.request(artifact, …)— resolve against the warm plan; defaults to the class the artifact was declared with. Accepts the same overrides assb.request.s.plan()— the broker's current placement plan (per-artifact host options and whether each is hot / planned / unavailable).s.close()— release (also onwith-block exit).
Session semantics
| Behaviour | Detail |
|---|---|
| Lifetime | Kept alive by a background heartbeat. Broker TTL defaults to 90 s; the client beats at max(5, ttl/3) — so a crashed client's plan is reclaimed automatically within the TTL. |
| A session is a subscription | sb.subscribe(...) is an alias for sb.session(...). It holds its artifacts warm and committed at their declared workload class for as long as it heartbeats. |
| Standing demand does not age | Unlike a queued one-off request (whose weight climbs with wait time), a session exerts steady pull at its priority — it will not starvation-climb over other work. See scheduling. |
priority | Baseline weight for this session's demand, and the default for its .request() calls. It biases which queued work wins a contended host; it does not override workload class — a higher environment/urgency still outranks it. |
| On close | The broker stops needing those artifacts. They stay warm only for their keep_alive/min_warm_sec window, then unload. Nothing stays loaded that no client needs. |
| Standing entries are never dispatched | Session demand appears in the broker queue as a warmth signal, not as work to run. A non-zero queue length in the reconciler log is therefore normal and is not a backlog — check queue_depth for real pending work. |
Verify a session is actually in use: sb.status()["sessions"] — if that is
0 while your app is running, you are making isolated request() calls and the
broker is scheduling each one blind to the next.
Agent & operator auth
Since 2026-08-29, /agent/* and the operator endpoints (/pin,
/unpin, /force-config, /restart, /admin/enable)
require the shared key: header X-Agent-Key matching
~/ai/secrets/agent.key on the broker box. Agents read it from
agent_key.txt on their share (deployed by deploy.sh). Operator example:
curl -H "X-Agent-Key: $(cat ~/ai/secrets/agent.key)" …. If the broker-side
key file is removed, endpoints revert to LAN-trust (open) — enforcement follows the file.
Usage statistics
/stats (browser) and /stats.json (tooling) show usage aggregates
aimed at spotting churn live: per-GPU heavy moves, power-guard blocks (rising count = the
scheduler is repeatedly being asked to do the thing that crashed hosts on 2026-08-29),
A→B→A alternations, per-artifact wait percentiles, and per-requester sessionless share +
advisory-flagged pairs. Aggregates only; email-shaped requesters are masked; counters reset on
broker restart. Same edge password as the dashboard.
Current defaults & retired services
Retirement is a gate, not a deletion: a retired artifact keeps its entry in manifests/artifacts.json with "disabled": true, is projected nowhere, and comes back by removing the flag. Clients must move to the replacement id; requests for a retired id queue until it is re-enabled.
| Retired id | Model | Since | Request instead | Why |
|---|---|---|---|---|
mistral-small-3.2-24b-q4 | Mistral-Small-3.2-24B-Instruct-2506 | 2026-09-08 | unseen-gemma4-26b-nsfw-q4 — UNSEEN Gemma 4 26B NSFW (Jommarn) | Roleplay A/B 2026-09-08: 8/8 checks on the 5090 and ai-151's 3090, 2.0x/2.7x faster than Mistral, 7/7 on the hard refusal probe (Mistral 5/7). Card claim verified by test. |
qwen3-30b-a3b-q4 | Qwen3-30B-A3B-Instruct-2507 | 2026-09-08 | qwen3.6-35b-a3b-heretic-v2-q4 — Qwen3.6-35B-A3B heretic v2 (trohrbaugh) | Director A/B 2026-09-08: 6/6 JSON, checks equal to the 30B, 79% of its decode on the 5090, 7/7 on the hard probe where the 30B refused the explicit scene. IQ4_XS on the 3090s. |
qwen3-8b-q4 | Qwen3-8B (Qwen/Qwen3-8B-GGUF, Q4_K_M) | 2026-09-08 | qwen3-8b-ziyon-nsfw-q4 — qwen3-8b-ziyon-nsfw (liuw15) | Small-lane A/B 2026-09-08 on ai-9's 1080 Ti: parity on speed, better on two checks, 7/7 on the hard probe (stock 8B 6/7). |
qwen3-vl-32b-q4 | Qwen3-VL-32B-Instruct | 2026-09-08 | darkidol-qwen3.8-27b-q4 — DarkIdol-Qwen3.8-27B v1.1 (aifeifei798) | Vision A/B 2026-09-08: 8/8 checks on the 5090 and the 3090, ~10% faster, ~2 GB lighter, 7/7 on the hard probe (VL-32B also 7/7). Built for TRPG scene art; card claim verified by test. |
darkidol-qwen3.8-27b-q4, qwen3-8b-ziyon-nsfw-q4, qwen3.6-35b-a3b-heretic-v2-q4, unseen-gemma4-26b-nsfw-q4.Artifact catalog
The host RAM budget is measured, not declared. ram_mib_budget in manifests/hosts/<host>.json gates how much CPU-placed work may co-reside. It is now a starting point: the reconciler derives it from the host’s own idle readings (total − worst idle − 2,048 MiB), applies it through the same catalog rebuild the GPU budgets use, and logs every move. Whatever the source, a budget may never claim the last 2,048 MiB of a machine — ai-151 declared 33,427 MiB against 32,689 physical, which can only be met by swapping, and a host that swaps slows every service on it at once. Unlike the GPU budgets, RAM budgets are applied, not just logged: the blast radius is one host’s CPU co-residency rather than the fleet’s busiest lane losing its fastest card.
Live as of 2026-09-08 (uncensored-defaults cutover). Authoritative source is sb.fleet().
This table is generated from /fleet, not hand-maintained.
disabled flag. A profile (or a
whole artifact) in manifests/artifacts.json can carry "disabled": true.
It stays in the catalog verbatim — listed below as (disabled) — but is projected
nowhere: not schedulable, not advertised by /fleet. Re-enable by deleting the flag;
nothing is ever removed. Use it to park a single service, or every profile of a host while its
hardware is investigated. Nothing is disabled at present ; anti-churn rests on the scheduler's co-residency
placement constraint and per-GPU heavy-swap cooldown.vram_mib_budget to 0 in manifests/hosts/<host>.json.
The budget feeds catalog.gpu_budgets directly, so every variant needing that GPU
becomes infeasible and nothing is scheduled there; other GPUs on the host are unaffected and the
broker hot-reloads. Its rows stay in the catalog below, because that table lists declared
inventory while placement comes from feasible_configs. Restore by putting the original
budget back. vram_mib_budget and does not subtract memory used by processes outside the fleet.
If you run your own workload on a fleet GPU, lower that budget to what the fleet may actually use
— otherwise the scheduler can place a model that will not fit alongside it and the load fails.| Artifact | Kind | Params | Host | Placement | Context | Cost | Port |
|---|---|---|---|---|---|---|---|
bge-reranker-v2-m3-q8 | rerank | 568M | ai-150 | cpu | 8192 | 1500 MiB RAM | 11501 |
comfyui-anime-depth | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-anime-inpaint | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-anime-lora | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-anime-lora-ref | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-anime-pose | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-anime-pose-ref | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-anime-ref | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-anime-sdxl | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-anime-view | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-appicon-flux1 | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-appicon-flux1 | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-appicon-transparent | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-appicon-transparent | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-depth-map | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-engine | comfyui-engine | — | ai-150 | gpu0 | — | 28000 MiB | 8188 |
comfyui-engine | comfyui-engine | — | ai-150 | gpu1 | — | 16000 MiB | 8189 |
comfyui-engine-sdxl | comfyui-engine | — | ai-151 | gpu0 | — | 14000 MiB | 8188 |
comfyui-flux-unchained | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-flux-unchained | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-flux1-q5 | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-flux1-q5 | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-foley-stableaudio | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-foley-stableaudio | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-foley-tangoflux | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-foley-tangoflux | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-location-flux-depth | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-location-flux-depth | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-map-sdxl | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-map-sdxl | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-music-acestep | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-music-acestep | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-scene-edit | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-scene-edit | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-scene-edit-masked | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-scene-edit-masked | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-scene-inject | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-scene-inject | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-scene-inject-masked | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-scene-inject-masked | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-scene-plate | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-scene-relight | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-sprite-base-flat | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-sprite-inpaint | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
comfyui-sprite-qwen-edit | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-sprite-qwen-edit | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-sprite-qwen-edit-ref | comfyui-workflow | — | ai-150 | gpu0 | — | 28000 MiB | — |
comfyui-sprite-qwen-edit-ref | comfyui-workflow | — | ai-150 | gpu1 | — | 16000 MiB | — |
comfyui-sprite-rgba-inpaint | comfyui-workflow | — | ai-151 | gpu0 | — | 14000 MiB | — |
darkidol-qwen3.8-27b-q4 | llamacpp | 27B (dense, gated-DeltaNet hybrid) | ai-150 | gpu0 | 32768 | 24000 MiB | 11514 |
darkidol-qwen3.8-27b-q4 | llamacpp | 27B (dense, gated-DeltaNet hybrid) | ai-150 | gpu1 | 32768 | 21000 MiB | 11514 |
darkidol-qwen3.8-27b-q4 | llamacpp | 27B (dense, gated-DeltaNet hybrid) | ai-151 | gpu0 | 16384 | 21000 MiB | 11514 |
faster-whisper | stt | — | ai-151 | gpu1 | — | 4000 MiB | 7861 |
faster-whisper | stt | — | ai-9 | gpu1 | — | 1600 MiB | 7861 |
gemma-4-26b-a4b-q4 | llamacpp | 26B (MoE, 4B active) | ai-150 | gpu0 | 16384 | 17000 MiB | 11504 |
gemma-4-26b-a4b-q4 | llamacpp | 26B (MoE, 4B active) | ai-150 | gpu1 | 16384 | 17000 MiB | 11504 |
gemma-4-26b-a4b-q4 | llamacpp | 26B (MoE, 4B active) | ai-151 | gpu0 | 16384 | 17000 MiB | 11504 |
kokoro | tts | — | ai-150 | gpu0 | — | 1500 MiB | 7852 |
kokoro | tts | — | ai-150 | gpu1 | — | 1500 MiB | 7852 |
kokoro | tts | — | ai-151 | gpu1 | — | 1500 MiB | 7852 |
kokoro | tts | — | ai-9 | gpu0 | — | 1500 MiB | 7852 |
kokoro | tts | — | ai-9 | gpu1 | — | 1500 MiB | 7852 |
lora-train-sdxl | trainer | trains rank-16 LoRA on the UNet attention projections | ai-150 | gpu0 | — | 24000 MiB | 11520 |
lora-train-sdxl | trainer | trains rank-16 LoRA on the UNet attention projections | ai-150 | gpu1 | — | 20000 MiB | 11521 |
lora-train-sdxl | trainer | trains rank-16 LoRA on the UNet attention projections | ai-151 | gpu0 | — | 20000 MiB | 11520 |
mistral-small-3.2-24b-q4 | llamacpp | 24B | ai-150 (disabled) | gpu0 | 32768 | 19000 MiB | 11503 |
mistral-small-3.2-24b-q4 | llamacpp | 24B | ai-150 (disabled) | gpu1 | 32768 | 19000 MiB | 11503 |
mistral-small-3.2-24b-q4 | llamacpp | 24B | ai-151 (disabled) | gpu0 | 32768 | 19000 MiB | 11503 |
pyannote | diarization | — | ai-150 | gpu0 | — | 2000 MiB | 7862 |
pyannote | diarization | — | ai-150 | gpu1 | — | 2000 MiB | 7862 |
pyannote | diarization | — | ai-151 | gpu1 | — | 2000 MiB | 7862 |
pyannote | diarization | — | ai-9 | gpu0 | — | 2000 MiB | 7862 |
pyannote | diarization | — | ai-9 | gpu1 | — | 2000 MiB | 7862 |
qwen3-30b-a3b-q4 | llamacpp | 30B (MoE, 3B active) | ai-150 (disabled) | gpu0 | 32768 | 23000 MiB | 11502 |
qwen3-30b-a3b-q4 | llamacpp | 30B (MoE, 3B active) | ai-150 (disabled) | gpu1 | 32768 | 21000 MiB | 11512 |
qwen3-30b-a3b-q4 | llamacpp | 30B (MoE, 3B active) | ai-151 (disabled) | gpu0 | 32768 | 21000 MiB | 11502 |
qwen3-4b-q4 | llamacpp | 4B | ai-150 | gpu0 | 16384 | 4800 MiB | 11507 |
qwen3-4b-q4 | llamacpp | 4B | ai-150 | gpu1 | 16384 | 4800 MiB | 11507 |
qwen3-4b-q4 | llamacpp | 4B | ai-151 | gpu0 | 16384 | 4800 MiB | 11507 |
qwen3-4b-q4 | llamacpp | 4B | ai-151 | gpu1 | 16384 | 4800 MiB | 11507 |
qwen3-4b-q4 | llamacpp | 4B | ai-9 | gpu0 | 4096 | 2900 MiB | 11501 |
qwen3-4b-q4 | llamacpp | 4B | ai-9 | gpu1 | 16384 | 4800 MiB | 11501 |
qwen3-8b-q4 | llamacpp | — | ai-150 (disabled) | gpu0 | 32768 | 8500 MiB | 11508 |
qwen3-8b-q4 | llamacpp | — | ai-150 (disabled) | gpu1 | 32768 | 8500 MiB | 11508 |
qwen3-8b-q4 | llamacpp | — | ai-151 (disabled) | gpu0 | 32768 | 8500 MiB | 11508 |
qwen3-8b-q4 | llamacpp | — | ai-151 (disabled) | gpu1 | 32768 | 8500 MiB | 11508 |
qwen3-8b-q4 | llamacpp | — | ai-9 (disabled) | gpu1 | 32768 | 8500 MiB | 11500 |
qwen3-8b-ziyon-nsfw-q4 | llamacpp | 8B | ai-150 | gpu0 | 32768 | 8500 MiB | 11517 |
qwen3-8b-ziyon-nsfw-q4 | llamacpp | 8B | ai-150 | gpu1 | 32768 | 8500 MiB | 11517 |
qwen3-8b-ziyon-nsfw-q4 | llamacpp | 8B | ai-151 | gpu0 | 32768 | 8500 MiB | 11517 |
qwen3-8b-ziyon-nsfw-q4 | llamacpp | 8B | ai-151 | gpu1 | 32768 | 8500 MiB | 11517 |
qwen3-8b-ziyon-nsfw-q4 | llamacpp | 8B | ai-9 | gpu1 | 32768 | 8500 MiB | 11517 |
qwen3-embedding-4b-q8 | embed | 4B | ai-150 | cpu | 32768 | 10000 MiB RAM | 11500 |
qwen3-embedding-4b-q8 | embed | 4B | ai-150 | gpu0 | 32768 | 7500 MiB | 11500 |
qwen3-embedding-4b-q8 | embed | 4B | ai-150 | gpu1 | 32768 | 7500 MiB | 11500 |
qwen3-embedding-4b-q8 | embed | 4B | ai-151 | gpu0 | 32768 | 7500 MiB | 11500 |
qwen3-embedding-4b-q8 | embed | 4B | ai-151 | gpu1 | 32768 | 7500 MiB | 11500 |
qwen3-embedding-4b-q8 | embed | 4B | ai-9 | gpu1 | 32768 | 7500 MiB | 11502 |
qwen3-vl-32b-q4 | llamacpp | 32B | ai-150 (disabled) | gpu0 | 32768 | 28000 MiB | 11505 |
qwen3-vl-32b-q4 | llamacpp | 32B | ai-151 (disabled) | gpu0 | 16384 | 21000 MiB | 11505 |
qwen3-vl-32b-q8 | llamacpp | 32B (dense) | ai-150 | all | 49152 | 47000 MiB | 11506 |
qwen3.6-35b-a3b-heretic-v2-q4 | llamacpp | 35B (MoE, 3B active) | ai-150 | gpu0 | 32768 | 26000 MiB | 11516 |
qwen3.6-35b-a3b-heretic-v2-q4 | llamacpp | 35B (MoE, 3B active) | ai-150 | gpu1 | 32768 | 21000 MiB | 11516 |
qwen3.6-35b-a3b-heretic-v2-q4 | llamacpp | 35B (MoE, 3B active) | ai-151 | gpu0 | 32768 | 21000 MiB | 11516 |
unseen-gemma4-26b-nsfw-q4 | llamacpp | 26B (MoE, 4B active) | ai-150 | gpu0 | 32768 | 22000 MiB | 11513 |
unseen-gemma4-26b-nsfw-q4 | llamacpp | 26B (MoE, 4B active) | ai-150 | gpu1 | 32768 | 21000 MiB | 11513 |
unseen-gemma4-26b-nsfw-q4 | llamacpp | 26B (MoE, 4B active) | ai-151 | gpu0 | 32768 | 21000 MiB | 11513 |
min_context exists.Co-residency & VRAM capacity
This is the most important operational section on this page.
Hardware budgets
| Host | GPU 0 | GPU 1 | System RAM budget |
|---|---|---|---|
ai-150 | RTX 5090 — 30,000 MiB budget (32,607 physical) · perf 100 | RTX 3090 — 22,000 MiB · perf 48 | 74,525 MiB |
ai-151 | RTX 3090 — 22,000 MiB budget (24,576 physical) · perf 48 | RTX 2080 Ti — 10,000 MiB · perf 32 | 33,427 MiB |
ai-9 | GTX 970 — 3,500 MiB (4,096 physical) · perf 8 | GTX 1080 Ti — 10,000 MiB · perf 18 | 27,300 MiB |
RAM budgets are derived, not guessed: commit limit minus measured baseline minus 10%. Windows commits system RAM to back VRAM at roughly 1:1, so a host running a 22 GB model needs that much commit charge available on top of its own working set.
perf is a relative throughput score used only as a tie-break
(see How placement is decided). It is anchored on memory bandwidth and
corrected by measurement — Qwen3-4B runs at 289 tok/s on the 5090, 84 on the
1080 Ti and 41 on the 970.
gpu0 is the GTX 970 and gpu1 is the GTX 1080 Ti. Nothing in the broker detects a slot swap: budgets, perf scores and the device pin inside every launch line are keyed by index, so the manifest must be corrected by hand when cards move.The GTX 970 budget is 3,500 of 4,096 MiB on purpose. That card's last
512 MB sits on a slow 32-bit partition; allocating into it collapses
bandwidth. It runs the speech stack (kokoro + pyannote fill it exactly at
3,500 MiB) and a reduced-context Qwen3-4B. faster-whisper is
deliberately not offered there: CTranslate2 ships no Maxwell kernels and
fails with cudaErrorNoKernelImageForDevice.
The large-model conflict
The big text and vision models each want a whole card. Since the 2026-09-08 cutover the live
lanes are qwen3.6-35b-a3b-heretic-v2-q4 (director) and
darkidol-qwen3.8-27b-q4 (vision + narration); unseen-gemma4-26b-nsfw-q4
(roleplay) is a 26B MoE in the same footprint class as the old Mistral profile.
| Pair | Host | Arithmetic | Fits? |
|---|---|---|---|
qwen3.6-35b-a3b-heretic-v2-q4 + darkidol-qwen3.8-27b-q4 | ai-150 gpu0 (5090) | 26,000 + 24,000 = 50,000 > 30,000 | No |
qwen3.6-35b-a3b-heretic-v2-q4 (IQ4_XS) + anything | ai-151 gpu0 (3090) | 21,000 of 22,000 | Alone |
darkidol-qwen3.8-27b-q4 + qwen3-embedding-4b-q8 | ai-151 gpu0 | 21,000 + 7,500 = 28,500 > 22,000 | No |
darkidol-qwen3.8-27b-q4 + kokoro | ai-150 gpu0 | 24,000 + 1,500 = 25,500 | Yes |
qwen3.6-35b-a3b-heretic-v2-q4 + qwen3-embedding-4b-q8 | ai-150 (gpu0 + cpu) | 26,000 VRAM + 10,000 RAM | Yes |
Declared budgets for the new ids are the placeholders they were registered with (file size plus KV and compute at ctx 32768, rounded up); the reconciler's vram reconcile log lines are the measurements to tune them from. Peak card usage sampled during the A/B runs: UNSEEN ~19,100 MiB on the 5090; DarkIdol ~22,000 on the 5090 at 32k ctx and ~19,600 on a 3090 at 16k.
The working three-model layout
A text + vision + embedding app still runs fully resident only with embeddings pushed to CPU:
ai-150 / gpu0 -> qwen3.6-35b-a3b-heretic-v2-q4 # 26,000 of 30,000 MiB (5090)
ai-150 / cpu -> qwen3-embedding-4b-q8 # system RAM, no VRAM
ai-151 / gpu0 -> darkidol-qwen3.8-27b-q4 # 21,000 of 22,000 MiB (3090, ctx 16k)
ai-150 / gpu1 -> unseen-gemma4-26b-nsfw-q4 # 21,000 of 22,000 MiB (3090), roleplay
Without placement="cpu" on the embeddings, the broker may park them on
ai-151/gpu0, which blocks the vision model entirely and forces a swap on every loop turn.
The retired ids (qwen3-30b-a3b-q4, qwen3-vl-32b-q4,
mistral-small-3.2-24b-q4, qwen3-8b-q4) stay in the catalog behind
disabled: true; see Current defaults & retired services.
The layout the fleet holds
Two hosts are pinned, so the character orchestrator's image lanes and vision gates never wait for a load:
ai-150 / gpu0 -> comfyui-engine # every ComfyUI lane (5090)
ai-150 / gpu1 -> darkidol-qwen3.8-27b-q4 # second vision arm (3090)
ai-151 / gpu0 -> darkidol-qwen3.8-27b-q4 # vision arm (3090)
ai-151 / gpu1 -> free # 2080 Ti, 10,000 MiB
ai-9 -> unpinned # speech and qwen3-4b-q4 on demand
scripts/ensure-comfy.sh asserts the ai-150 pin with the engine and keeps the second arm
(KEEP=). While these pins hold, no 22,000 MiB-class card is free:
qwen3.6-35b-a3b-heretic-v2-q4, unseen-gemma4-26b-nsfw-q4 and the offline deep-work tier
qwen3-vl-32b-q8 (both ai-150 cards, 28,500 + 18,500 MiB) need a pin released first.
GET /status shows each host's pinned flag and current_config.
Client API — core
request()
sb.request(artifact, *, environment, urgency, keep_alive=30.0, requirements=None,
priority=0.0, placement=None, gpu=None, requester=None,
broker=None, timeout_sec=300.0) -> str
Blocks until a healthy endpoint exists; returns the proxy URL. Raises
RequestTimeout on timeout, AIError on failure/cancel,
UpgradeRequired if the client is too old.
session() / Session
sb.session(requires, *, priority=0.0, requester=None,
broker=None, ttl_sec=None) -> Session
sb.subscribe(...) # alias — a session IS a subscription
ttl_sec overrides the session lifetime, clamped to 30–3600 s (never rejected).
Default is 600 s. Session.ttl_sec reports what the broker actually granted, which may
differ from what you asked for.
Session.advisories lists requirements that some host cannot hold
at the same time. Each entry is
{artifacts: [a, b], conflict_on: [hosts], coresident_on: [hosts]}. Alternating between a
flagged pair makes that host unload and reload gigabytes on every switch — the broker cannot avoid it,
only report it. Each conflict is also logged once at session open.
comfy_run()
sb.comfy_run(artifact, params=None, *, environment, urgency,
keep_alive=600.0, timeout_sec=600.0,
poll_interval=3.0, requester=None,
broker=None) -> list[dict]
Runs a ComfyUI workflow artifact end to end and returns its outputs. comfy_run() does the five steps a caller would otherwise repeat — resolve the graph, get a proxy, submit, poll, fetch — with the cold-start retry included.
outs = sb.comfy_run("comfyui-foley-stableaudio",
{"prompt": "birds chirping at dawn",
"seconds": 15.0, "seconds_total": 15.0},
environment="production", urgency="background")
open("birds.mp3", "wb").write(outs[0]["data"])
Returns [{filename, subfolder, type, kind, node, data}] — kind is
ComfyUI's output bucket (audio, images, …) and data is bytes.
The graph is fetched from the broker rather than hardcoded, so a workflow revision cannot silently
break callers, and unknown params are rejected rather than ignored.
timeout_sec expires; do not treat the first failure as fatal.Introspection
| Function | Returns |
|---|---|
sb.status(broker=None) | Broker snapshot: enabled, queue_depth, queue, sessions, hosts. |
sb.fleet(broker=None) | Full inventory: per-host GPUs (name, budget, used, util) and every service variant. |
sb.voices(broker=None) | TTS voice list (union + per-host), cached ~60s server-side. |
Cancellation
sb.cancel(ticket, *, broker=None) # cancel a QUEUED ticket
sb.cancel_ticket(ticket, *, broker=None) # release a DISPATCHED ticket
Client API — speech
Text to speech
sb.tts_say(text, *, character_voice="af_bella", language="en", speed=1.0,
chunk_min_chars=50, chunk_max_chars=300, inter_chunk_gap_ms=80,
pronunciation_overrides=None, output_path=None, bundle=None,
environment="production", urgency="realtime", artifact=None,
priority=0.0, timeout_sec=300.0) -> bytes | str
sb.tts_say_stream(text, ...) # generator of (chunk_text, wav_bytes)
Output is 24 kHz mono float32 RIFF/WAVE. tts_say buffers into one WAV (bundling on by
default); tts_say_stream yields per chunk so you can start playback immediately. Chunks
are silence-trimmed, with inter_chunk_gap_ms of breath prepended after the first.
Speech to text
sb.transcribe(audio, *, language="en", initial_prompt=None, model=None,
word_timestamps=True, hotwords=None, no_speech_threshold=None,
low_confidence_threshold=None, suppress_hallucinations=True,
hallucination_blocklist=None, ...) -> dict
audio accepts bytes, a path, or a file-like object.
async with sb.transcribe_stream(language="en", hotwords=["Karrthûn"],
silence_ms=700, min_speech_ms=100,
max_utterance_ms=30000,
partial_interval_ms=300) as s:
await s.send(pcm_int16_16k_mono)
async for ev in s.events(): ...
Event types: ready, vad_start, partial, final,
vad_stop, error. Send raw int16 16 kHz mono PCM; any frame size works.
hotwords is the highest-value accuracy lever. Proper nouns
(character names, places, jargon) are what generic models get wrong. The service runs a two-pass
decode arbitration so biasing improves spelling without hallucinating the word list.TTS markup
tts_say* accept inline SSML-style markup:
| Tag | Effect |
|---|---|
<break time="500ms"/> | Silence of that length (emitted as a silence-only WAV). |
<break strength="weak|medium|strong|x-strong"/> | 150 / 300 / 600 / 1000 ms. |
<voice name="am_eric">…</voice> | Switch voice for the enclosed span. |
<lang code="ja">…</lang> | Switch language pipeline. |
<prosody speed="1.2">…</prosody> | Rate for the span (0.5–2.0). |
<sub alias="…">…</sub> | Speak the alias instead of the text. |
<phoneme>…</phoneme> | Explicit pronunciation. |
Supported language codes: en, en-us, en-gb, es, fr, hi, it, pt, pt-br, ja, zh.
pronunciation_overrides={"gaol":"jail"} applies a dict globally.
sb.split_sentences(text) exposes the sentence splitter.
Client API — admin
| Function | Effect |
|---|---|
sb.pin(host, config) | Lock a host to a config. The scheduler will not swap it until unpinned. |
sb.unpin(host) | Release the pin. |
sb.force_config(host, config) | Force a host to a config immediately. |
sb.restart(host, service) | Restart one service on a host. |
sb.set_enabled(bool) | Global dispatch flag. When false, /status still answers but nothing dispatches. |
finally,
and never leave a pin in place during normal operation.Benchmarking exact hardware
A testing feature, switched off by default. It pins a request to one specific GPU so the same work can be measured on different hardware.
url = sb.request("qwen3-4b-q4", requirements={
"gpu": "ai-9:gpu1", # exact card: <host>:<placement>
"bench_code": "bench-...", # ask the operator
})
print(sb.last_placement) # confirm what actually served it
One field, not two. Neither half identifies hardware alone — a host does not say which GPU, and an index does not say which machine:
| Pin | Card | Pin | Card |
|---|---|---|---|
ai-150:gpu0 | RTX 5090 | ai-150:gpu1 | RTX 3090 |
ai-151:gpu0 | RTX 3090 | ai-151:gpu1 | RTX 2080 Ti |
ai-9:gpu0 | GTX 970 | ai-9:gpu1 | GTX 1080 Ti |
gpu0 is the GTX 970 and gpu1 the GTX 1080 Ti. Always read
sb.last_placement to confirm what served your request rather than trusting a
pin written earlier.A bare host ("ai-9") or bare index ("gpu1") returns
400 rather than being quietly ignored — a mistyped benchmark that runs on
the wrong hardware is worse than one that fails.
This is not a scheduling control. A pin removes the broker's ability to route around a busy or failing card, which is the entire reason the scheduler exists. Used for normal traffic it would undo load spreading, the warm-host preference and every anti-thrash guarantee. Ask for a code, run the comparison, expect it to be revoked.
Without a valid code the broker returns 403. The gate fails closed: no
codes issued — the normal state — means nobody can pin, and an empty or corrupt codes
file means the same. Accepted pins log as BENCH hardware pin from
<requester>, refusals as REJECTED hardware pin.
gpu_index and placement="gpuN" still work but need the same
code, because they are hardware targeting by another name. Semantic placements need no
code — "cpu", "all", "single" and
"mixed" name a shape rather than a card and mean the same thing on
every host.
simulate_load rides the same gate and answers a different question: not
"how fast is this card" but "where would this land if the fleet were busy" —
requirements={"simulate_load": {"ai-150": 5}, "bench_code": "..."}.
How placement is decided
When a queued request has no running worker, the broker picks an owning host by sorting candidates on this key — lowest wins:
(evict, warmth, taken, in_flight, strength, host)
| Term | Meaning |
|---|---|
evict | 0 if the host can serve without evicting what it's already running/heading toward. Dominates everything else — this is what spreads a workload across the fleet instead of thrashing one box. |
warmth | 0 if the host is already running or heading toward a service that serves this request. Deliberately request-specific: a host merely running something unrelated (e.g. the audio stack on its other GPU) is not warmer for this work and must not out-rank an idle host with better hardware. (Fixed 2026-07-31 — see change log.) |
taken | How many hosts have already been claimed for other requests in this same scheduling tick. Spreads a burst of concurrent work instead of piling it all onto one box. |
in_flight | Least loaded. Sits above strength deliberately (changed 2026-08-26). When speed ranked first, the fastest host won even when busier and the slowest box never got a turn — ai-9 sat completely idle while ai-150 carried three services, because perf 18 always lost to perf 100 regardless of load. |
strength | (−tested_context, −perf, −vram) — prefers the stronger run-profile: the larger tested context first (a caller must not silently lose window size), then the faster GPU, then VRAM. With every host equally idle this picks the fastest capable card, which is the case that matters for a cold start; it does not override load-spreading once something is running. |
host | Deterministic final tiebreak (stable scheduler). |
Busy workers (2026-09-08). A service whose /health JSON says "busy": true is
skipped by dispatch, breaks the continuity rule (a host whose only workers for the capability are busy does
not claim the request), and counts as demand for idle-unload, eviction and the autoscaler's drain. This is
how a second lora-train-sdxl request lands on another card while the first is training instead of
queuing behind it: in-flight accounting only sees the millisecond POST /train, not the ten-minute job.
Any service can opt in by reporting busy.
Among already-running replicas, pick_worker_for sorts by
(worker_load, strength, host, service).
Aging. A queued request's weight is priority + age_factor × wait_seconds
(age_factor = 1.0), so waiting work climbs and cannot starve forever. Standing (session)
demand does not age — it is steady pull at its priority, not a starvation climb.
Swapping. A host only swaps to a new config when the aged weight of the work the new config
serves exceeds what's currently served plus a swap_penalty of 30.
Warmth, TTLs & eviction
| Setting | Default | Meaning |
|---|---|---|
keep_alive | 30 s | Warm-linger after a request. |
reserve_mib (per GPU) | 0 | VRAM on that GPU the fleet will not plan to use, for a card shared with anything outside the fleet. Subtracted from vram_mib_budget when feasibility is computed. Defaults to 0 and is currently set on no host. |
min_warm_sec | per-artifact | Physical floor: a freshly loaded model won't be churned by equal/lower-rank work for this long. |
idle_timeout_sec | 30 min | Drain a non-empty host to empty after this idle window. |
min_config_tenure_sec | 15 s | Minimum time a config is held before reconsidering. |
cold_start_grace_sec | 180 s | While cold-starting, hold the decision this long — a big model takes 30–60 s and re-deciding every tick would thrash the agent. |
ttl_sec (session) | 600 s | Session lifetime without a heartbeat. Client-settable on POST /session, clamped to 30–3600 s. Was 90 s — shorter than a normal pause for a person who reads, thinks, then types. |
proxy_ttl_sec | 15 min | Minimum life of a ready ticket's proxy URL. Since 2026-09-08 the URL lives max(proxy_ttl_sec, keep_alive) and every proxied use extends it by another proxy_ttl_sec, so a client polling a long job (LoRA training) through its ticket keeps it. |
queue_ttl_sec | 10 min | A never-dispatched request older than this is treated as abandoned and reaped. |
in_flight_ttl_sec | 60 s | Window during which a dispatched request still holds the host. |
load_fail_threshold / load_backoff_sec | 2 / 600 s | Circuit breaker: a service that never reaches healthy this many times in a row is benched, so a model that genuinely can't load (OOM, missing file, crash-on-start) can't loop forever. |
proxy_ttl_sec is 15 minutes but
keep_alive defaults to 30 seconds. A ticket can report ready while its backend
has already been unloaded — calls then fail with upstream unreachable. Use the URL promptly, or
hold the model with a session / longer keep_alive.max(keep_alive, min_warm_sec) from its last touch. A client that opens a session, makes
one call, and closes it leaves no standing demand between calls — so the model survives only
as long as that window, no matter how long the session TTL is. kokoro keeps a warm floor of 1800 s so a pause in speech does not cost the next line a cold start. Hold one session for the whole feature (open at start,
heartbeat, close when done); that is what standing demand is for. Where a model is small and nothing
contends for its VRAM, raising its min_warm_sec is the belt-and-braces fix.Autoscaling
The broker horizontally scales replicable services under sustained load. On by default; opt out
per-artifact with "autoscale": false.
| Setting | Default | Meaning |
|---|---|---|
worker_capacity | 2 | Concurrent requests one replica absorbs before it's "full". |
scale_up_backlog | 3 | Backlog beyond capacity that justifies a new replica. |
scale_up_sustain_sec | 8 s | Backlog must persist this long (cold starts aren't free). |
scale_down_idle_sec | 90 s | Drain an extra replica after this idle. |
Conservative by design: only a real, persistent backlog on free hardware triggers a replica, and autoscaling never evicts live work.
min_replica_life_sec (300 s) floors how long a replica lives once healthy, the
sustained-backlog window never falls below the artifact's min_warm_sec, and hosts already
cold-starting the service are not offered as placement targets.Orchestrators
Two kinds of thing answer a request on this fleet:
service input -> output orchestrator input -> internal review -> [regeneration] -> output
A service is one model or one ComfyUI lane on one GPU, launched by a host's agent: it returns what it produced, and whether that was any good is the caller's problem. An orchestrator owns the outcome: it requests services through the broker in an order it knows, reviews what comes back — by measurement, by a vision model, by a decision model where the call is a judgement — regenerates what fails, records why, and returns a result the caller can use without looking. The arrow shape is the simple case, not a rule: a real orchestrator reviews at every stage, regenerates the source when a defect is inherited, and asks the fleet to decide when the rule has exceptions.
Orchestrators run on the broker box, hold no GPU, and are broker clients like any project. They are catalog artifacts of kind: "orchestrator", so a client finds them the way it finds a model — sb.request("character-orchestrator") returns the endpoint — but they carry an endpoint, not run-profiles: no agent launches them. Code: orchestrators/.
character-orchestrator — a visual-novel character, background and shot from one request (client: renai)
Base http://192.168.1.13:8092, LAN only, JSON over HTTP, CORS open; every route below is on it, and every job is asynchronous (a POST returns {job, state}, GET /jobs/<job> follows it whatever kind of work it is, and GET /characters/<job> answers the same). Three things it makes:
- Characters.
POST /characterswith an identity line, a requiredheight(5'2"or157 cm), a wardrobe, poses and expressions, and optionally an engine profile. Her first render is her reference sheet, made from those words alone - a new character has no images to reference: front, back and side views plus two head panels, composed on a ruled height backdrop with every full-body view scaled so the top of her head sits at her stated height. The sheet is her root.base.pngis its front view with her default outfit put on it, and every later frame is an edit of that, so her proportions and her face are settled before any clothes exist. The sheet's body is a featureless figure-drawing mannequin only when the spec declares"adult": true; every mannequin view is asked whether it shows explicit detail or reads as a child, and either answer refuses it. Output is a directory of frames pluscharacter.json: flats, body-plus-face layers, camera-shot rectangles, illuminant multipliers, her sheet, her painted references, and every decision with its reason.GET /characters/<id>/blocksis the character as one block per stage, the sheet leading, each with its images, the gates' answers and what is missing;POST /characters/<id>/redore-runs one block,POST /characters/<id>/addadds one piece to her spec and renders only that. - Backgrounds.
POST /backgroundsrenders a place with nobody in it, at1824×1248so a crop still holds its detail, and measures its ground: each spot the request names is probed by adding one adult of the standard height inside a band mask there, measuring the figure the model draws and discarding the probe — twice, on two seeds, and the spot is believed only when the two agree (see measuring a place's ground); two or more spots also give a ground-plane equation, so a character can be placed between them. A posture is measured the same way, once per spot and pose, and cached on the background. A place that already exists is asked for more without a prompt:meantrecords what it should have,makebuilds the weathers, lightings and dressings it lacks, andspotsfinds and measures a spot again.GET /backgrounds,GET /backgrounds/<id>, andGET /backgrounds/<id>/blocks— the place as one block per stage (place, ground, weathers, lightings, variations, shots;atmospheresis kept besidelightingsas its old name for a release), each carrying what exists, what was rejected, what the place is meant to have, what is missing and what is available, so a client shows a gap instead of inferring it from file names. - Shots.
POST /shotsis a picture of a background with people in it: a cast is placed spot by spot, far to near, each character scaled so that she is her stated height there, injected inside her own silhouette with room for a contact shadow, composited onto the background and relit, then measured from the spot's ground row to the top of her head. A shot may name anatmosphere— a weather (rain), a lighting (dusk) or both (rain@dusk) — and any number ofdressings (all of them links built on the background, made once and reused) and acamera—wide,waistorclose, a crop of the finished picture around whoever it is on. Every framing is cut from that one picture and listed on the manifest asframings;cameraonly says which one the shot leads with. The same cast shot again in the same place supersedes the earlier shot — the old picture moves toevidence/with a version record and the manifest'sversioncounts up — while a shot given an explicitidis a shot of its own.GET /shots,GET /shots/<id>,GET /shots?background=<id>.
Its library of poses, expressions, props, hair styles, weathers, lightings, blink frames and dressings is versioned data a client grows over /library/<kind>: an edit makes a new version, old versions are kept, and every job records the versions it rendered with. Soft dressing is fleet-rendered and client-chosen: things that touch the place (birds, litter, a parked car, figures in the distance) are baked as variants of the background, and things that do not (a pigeon, a cat, a cup) are rendered as transparent cutouts the client places at runtime (POST /dressings/layers, /files/dressings/). /files/ serves every output read-only and POST /verdicts writes a person's judgement into the corpus the vision gate is scored against. Every service can also be driven by a plain-language conversation (/talk): a local LLM collects the values, relative words move a number by its field's step, exact values the person gives are kept, and nothing runs until POST /talk/<conversation>/run; the contract is published at GET /talk/services. A place is the root of a chain of links, one kind of operation each: a weather (<id>~rain.png) adds material over the whole frame and is made by the unmasked edit lane at low denoise, because a ControlNet holds edges and rain has none in a dry place; a dressing (+birds) adds a thing inside a region found from its own words; a lighting (@dusk) changes how the surfaces already there look, held in place by the picture's own edges. Each link names its parent and is cached, so one weather serves every lighting built over it, and weather comes before lighting because an overcast wet park lit at dusk is not a dry park lit at dusk with rain added afterwards. How much of the place each link held is measured and kept on its version, and a shot taken in a weather that moved the ground says so. The older scene path — plates with placeholders painted into them — was removed on 2026-09-11; a place is a background and a picture with people in it is a shot. A character's blocks say what each stage is meant to hold and what is missing, hair is a stage of its own, and POST /characters/<id>/add adds one piece to her spec and renders only that. It renders through comfyui-sprite-base-flat, comfyui-sprite-qwen-edit and -ref, and comfyui-scene-plate / -inject / -inject-masked / -edit / -edit-masked / -relight; a vision model gates every stage (CHAR_VLM, default darkidol-qwen3.8-27b-q4) and a director model makes judgement calls (CHAR_DIRECTOR). Contract and fields: orchestrators/character/README.md. The vision gate's accuracy is measured against a labelled corpus (/mnt/ai-data/renai/labels/, tools/vlm_labels.py rescore) and a different vision model is adopted only if it scores better there.
A place and the links built on it
A base is rendered in flat overcast light, because every link is built on it and a lighting pass only partly redraws shadows — whatever light a base is born with is nearly permanent, and 0.69 to 0.89 of one place's midday shadow pattern survived into every hour of it. The flat light is asked for positively and the things that would spoil it are forbidden in the negative prompt: written as “no cast shadows” in the positive prompt it was ignored on every seed, moved to the negative it held on every seed, and saying nothing about light at all was no better than asking wrongly, because the model then supplies its own idea of the subject. Neither names a fixture — whether a light is switched on belongs to the hour, but the engine cannot know whether a place has a street lamp, a neon sign or a screen, so both say “artificial light”.
The plate is then asked whether that light arrived: is direct sunlight visible (hard-edged
shadows, sunlit patches), and is any artificial light source switched on. A base that fails is
re-rolled, because it is the root everything inherits. A base's light can also be redone without
redrawing the place — POST /backgrounds {"remake": ["light"]} relights it flat while
a canny map of its own edges holds the geometry, so the ground, the spots and their probes stay valid.
It cannot rescue a base whose shadows are already hard: a hard shadow is an edge, so the lock
that holds the geometry holds the shadows too.
Which lane draws a weather is a property of the library entry (meta.lane), not
of the kind. Weather that is only the state of the sky — clear, cloudy,
overcast — is drawn by the locked relight lane, because a flat sky has almost no
edges for a ControlNet to hold and the lane may repaint it while every fixture stays put. Weather that
puts material on the surfaces of the place — rain, snow,
fog — is drawn by the unmasked edit lane at low denoise, which is the dial between
“holds the place” and “free to change it”.
Every link is measured and gated. structure_kept records how much of the parent
survived and goes on the version; a lighting is held to 0.80 and rolled again — loosening the
ControlNet each time — before the light-field fallback is tried at all. The weather gate is asked
of the pair, the place and the weather side by side: does the right picture fail to show the
change, and is anything in it drawn wrong. That shape matters — measured against the label
corpus, the gates that work on this service are numbers and side-by-side pairs, while single-picture
yes/no questions miss most of what a person calls bad.
What that leaves, stated plainly because a client should not discover it by asking: a lighting must
not claim what its weather forbids, so @noon (“shadows short and directly beneath
things”) cannot be satisfied under overcast or rain, which say nothing
casts a hard shadow. snow falls but does not lie. cloudy and fog
are refused more often than not. A refused link is simply absent, and the block's
missing says so.
Talk — driving any service in plain language
Every service above can be driven by a conversation instead of a hand-built request body. A local LLM on the fleet reads the message against the service's field list and the target's current values and names the operations it collected; the orchestrator computes every value from those operations, and nothing runs until the run call. No Claude is involved at any point.
The contract is machine-readable and needs no POST to discover: GET /talk/services
returns every service, each one's fields and target_keys, and a
contract object giving the exact request and reply shape. It is a GET, so a
read-only client can fetch it and build against it rather than guessing. The shape is repeated here so
it is greppable, but GET /talk/services is the authority — if the two ever disagree,
the endpoint is right.
GET /talk/services every service, its fields, its target_keys, and `contract`
POST /talk a message; returns the conversation and its reply
GET /talk/<conversation> transcript, current values, run and supersedes (survives a refresh)
POST /talk/<conversation>/run 202 {job, state, supersedes, calls}
GET /jobs/<job> follow the work the run started
POST /talk — request body:
{"service": "a name from GET /talk/services",
"target": {...}, the keys in that service's target_keys
background: {background, stage}
shot: {background, shot, stage}
character: {character, block, names, add}
"conversation": null, null on the first message, then the id from the reply
"message": "what the person wrote",
"pinned": {field: value}} sent on every message and on run; the model never changes one
POST /talk — reply (GET /talk/<conversation> returns the same, minus
reply):
{"conversation": "c-a7b4d9cb",
"reply": "one or two sentences, or a question",
"fields": {field: {value, source, said, was, clamped}},
source is said | relative | current | default
`was` and `said` are what it changed and why
"missing": [], required fields with no value yet
"ready": true, nothing missing and nothing asked
"run": [{"method": "POST", "path": "/backgrounds", "body": {...}}],
the exact calls the run will make
"supersedes": ["a.png", ...], pictures that become earlier versions when it runs
"messages": [...], the transcript
"pinned": {...}, "runs": [...]}
run and supersedes are what a client shows beside the button:
this is what will happen, and this is what it will set aside. Both are on the
GET as well as on the turn that produced them, so a page refresh does not lose them.
A conversation is continued by posting again with the same conversation id.
Worked example — changing the words of a background that already exists:
POST /talk
{"service": "background.make", "target": {"background": "park-bench-bg"},
"message": "make the path narrower and put a low stone wall along the left side"}
-> {"conversation": "c-a7b4d9cb",
"reply": "prompt changed (make the path narrower ...). This sets aside 18 pictures -
park-bench-bg.png, park-bench-bg@dawn.png, ... and 12 more; they move to
earlier versions. I narrowed the path and added a low stone wall ...",
"fields": {"prompt": {"value": "<the new words>", "source": "relative",
"said": "make the path narrower ...", "was": "<the old words>"},
"spots": {"value": {...}, "source": "current"}},
"run": [{"method": "POST", "path": "/backgrounds",
"body": {"id": "park-bench-bg", "prompt": "...", "remake": ["place"], ...}}],
"supersedes": ["park-bench-bg.png", "park-bench-bg@dawn.png", ...],
"ready": true, "missing": []}
Note the remake: a place that already exists is not redrawn by a change of
words unless the call says so, so a conversation that edits its prompt asks for the redraw —
and a redraw takes the ground, the spots and every weather, lighting, dressing and shot built on
those pixels, which is what supersedes is naming before you commit.
The run body carries keys the field list does not publish, because it is the call as the
orchestrator will make it rather than a list of collected values. GET /talk/services
returns these under contract.reply.run_body_keys; they are repeated here so a grep finds
them:
remake what must be built again although it already exists.
THIS IS WHAT MAKES A RUN DESTRUCTIVE.
["place"] redraws a background, and its ground, its spots and every
weather, lighting, dressing and shot go with it
["light"] redoes only its light and keeps the ground
absent the call can only ADD; it cannot replace anything
make the links to build on a place that exists: weathers, lightings,
dressings, and `on` for the node to build them over
meant what the place is declared to have, so a client can show what is missing
A client that can read remake can tell a draft from a replacement without guessing,
and supersedes then names exactly what the replacement will set aside — computed
from the id the run will WRITE to, so pinning a new id supersedes nothing.
Words to values: a stated number is set as stated; slightly moves a numeric
field by one of its steps, more or less by two, a
lot by four; double and half scale it; as many as you can
and the least take its max and min; put it back takes the previous value.
Everything clamps to the field's range, and a clamped value says clamped: true.
Errors: 404 unknown service or conversation; 409 a run with values
still missing; a message the model cannot place returns a question in reply with
ready: false rather than a guess.
Drafts — working beside a thing, then taking its place
A conversation can build beside the thing it is about instead of replacing it, so a person
can look at the result before anything downstream is touched. A draft is not a new kind of file: it is
another object of the same kind with a derived id — park-bench-bg-draft for
park-bench-bg — which is what makes it non-destructive by construction. It writes
somewhere else, so nothing built on the original can be touched, and it is a first-class object while
it exists: its own blocks, its own /files/ urls, its own measured ground. That last point
is the reason for the design rather than a candidate slot: whether a new place is usable at all is
decided by its ground, and a ground is invisible in a picture.
POST /talk/<conversation>/run {"draft": true}
-> {"job": "...", "state": "queued", "supersedes": [],
"draft": {"id": "park-bench-bg-draft",
"kind": "background",
"of": "park-bench-bg",
"blocks": "/backgrounds/park-bench-bg-draft/blocks",
"files": "/files/backgrounds/",
"promote": "/backgrounds/park-bench-bg/promote"}}
Read the draft object rather than deriving the id: the naming rule may change, and a
client that derives it would break silently. A draft exists only once its job succeeds — a build
that fails puts its leavings in evidence, so nothing is left in the working directory claiming to be
an object that GET would answer 404 for.
Aiming at the parent or at the draft means different things, and both are intended. A run
aimed at <id> builds from the original plus the conversation's words. A run aimed at
<id>-draft builds from the draft, and that is how you iterate: each rebuild
leaves a version on the draft carrying the instruction that caused it, the seed, the job and the
conversation, plus the picture it replaced — so the edits behind a draft can be read back and an
earlier one returned to. The history lives here, not in a browser.
POST /<backgrounds|shots|characters|dressings>/<id>/promote
{"from": "<draft id>", "force": false}
-> {"promoted": "park-bench-bg", "from": "park-bench-bg-draft",
"kind": "background", "log": [...]}
Promote is the only moment anything is superseded. Everything the target had is set aside
— it was drawn from pixels that are being replaced — and nothing is rebuilt: a link is
made again when something next asks for it. The draft's own records come with it, because its ground
was measured in ITS pixels and they are the only ones true of the picture being promoted. A draft that
is not fit to take the thing's place is refused with the reason (a place whose spots never measured
cannot be stood in), and force: true overrides that.
Nothing here deletes: a superseded picture is renamed, recorded in the version ledger, and moved to
the evidence tree, where it is still served at /files/evidence/.... That is what makes a
promote reversible and an earlier version openable.
Seasons — a place across the year
A location is drawn once, on its own lane (Flux), from the words a person wrote plus the profile's look and a flat overcast light. Everything after that is a modifier put onto that picture by the image editor, which holds the place: the same furniture, the same camera. Seasons, weathers and hours all go the same way, so a season of a location is that location, not another drawing of a similar one.
Each modifier carries a strength in the library — how far it may repaint what it is put on. A season changes what lies on a place and must not rebuild it (0.8); a weather or an hour changes how the whole picture reads (1.0). Two tiers are available: a draft (4 steps) picks a seed, and a final (20 steps) is what gets judged — a draft predicts the layout at its seed, not the look.
A location knows whether it is a room, asked of its picture twice in different words and kept on its record. In a room a modifier edits only the view through the windows and the light coming in: applied whole, winter replaced a cafe's street with open fields and rain put puddles on a classroom floor. An interior is drawn with its view through the windows from the start, so every node of it shows the same street. No modifier may add trees, branches or poles, and none may add fog or haze unless its words ask for it.
The chain as a graph
A place is a graph, and the fleet answers in nodes. A client must never work the tree out by
parsing ~ @ + !: the naming rule belongs to the fleet and has already changed once.
GET /backgrounds/<id>/graph -> {background, nodes, edges}
GET /backgrounds/graph/blank ?concept=<words> | ?conversation=<c>
GET /talk/new every kind that can be made from nothing
GET /backgrounds/graph/contract the machine-readable shape
Each node carries id, kind (place · ground · season
· weather · lighting · dressing · attempt), label,
state, picture (a /files/ url or null), parent,
why when it failed, facts, the talk service and target that
define it, talking (conversations aimed at it), and branches — what
can still be made FROM it.
The five states have real sources: empty from disk, defined from a talk
conversation that is ready and unrun, running from the job queue, built from a
picture, failed with a reason. Four of the five have no picture, and "never asked for" is not
"tried and failed". ground never has a picture — it carries its numbers, because
it is the link that fails most and the only failure invisible in a picture.
A failure is a branch, not a stop. Every rejected try is kept as a node of kind
attempt with its picture and its reason, so the canvas shows what was tried and why it
died. A reason is classified — a measurement turns a dial, prose about the picture rewords
— and is verified before it changes anything: the model's claim goes back as a factual
question, because its judgements fabricate while its facts hold. A reword puts the fault in the
NEGATIVE prompt and a restatement of what should happen in the positive, never the fault itself.
Taking a thing back out
An object is retired with a DELETE on its own url. Everything it owns — its
picture, its record, its weather and lighting links, its version ledger — moves to the evidence
tree under a .retired-<stamp> name, the id answers 404 and it leaves the listings.
This is the same thing promote does to what a draft replaces, so nothing is deleted and a
retirement can be undone by hand from evidence.
DELETE /<backgrounds|shots|characters|dressings>/<id> {"force": false}
-> {"retired": "talk-test-bench", "kind": "background", "files": 6, "log": [...]}
A background refuses while a shot stands in it — a shot names its place in its own id
and could not be built again once the place is gone — and says which shots; force:
true overrides that. DELETE /characters/<job id> still cancels a queued job,
and a job id is matched first, so the two cannot collide.
How failure teaches a modifier
A modifier — a season, weather, lighting or dressing — is drawn from its words in the library, and those words are what a failure changes. A lesson is written as a new version of the modifier, so every later build inherits it and one call rolls it back. Three things teach:
- a bad verdict with a reason on a picture (
POST /verdicts, or the verdict talk service) — checked in the background, once per modifier however many of its pictures were named; - a season build whose attempt fails with a confirmed fault — once per build;
- a weather or lighting that fails every rung of its control ladder, from its last try's reason.
A lesson goes only to the modifier that made the picture: the last mark in its id
(p!spring~snow@dusk was made by dusk, from p!spring~snow). The
fault must be confirmed in the picture and absent from the picture it was drawn from — litter
a location already has is not spring's doing. A location's own plate was made by no modifier and teaches
nothing. The rewrite may not introduce a person, a species, or anything belonging to another layer
— a season may not name the sky or the hour, a weather may not name the hour, a lighting may name
both — and a rewrite that does is asked once more with those words named, then refused. When the
words could not have caused the fault, nothing is written.
POST /verdicts {"verdict": "bad", "images": ["/files/backgrounds/park-bench-bg!spring.png"],
"why": "petals on the ground and no tree that could shed them"}
-> {"judged": 1, "no_row": [], "lessons": {"checking": ["park-bench-bg!spring.png"], "see": "GET /lessons"}}
GET /lessons
-> {"lessons": [{"modifier": "spring", "kind": "seasons", "taught": true, "version": 3,
"words": "in spring: new pale-green leaves only half out, ...",
"fault": "...", "picture": "park-bench-bg!spring.png", "by": "operator", "at": "..."}]}
A location's own plate is made by no modifier, so a fault on it teaches the location's words,
versioned in its record (prompt_version, prompt_history). The rewrite may not
name a weather, the sky's condition, the hour or a person, has any season taken out, and must keep every
phrase of the old words the fault was not about. A lesson on a draft redraws it once; a live
location is never replaced by a lesson — promote decides that. Slop claims do not teach: checked
against the picture they come back reversed.
A row with taught: false carries why: not confirmed in the picture, already in
the parent, unrelated to the words, or refused for naming what the modifier may not.
Measuring a place's ground
A spot is declared in words, found in the picture with a labelled grid — six columns by four rows, then its row asked again of a full-width strip around that row under four finer rows, because the model reads columns well and rows poorly — and then rolled until two measurements agree: a person of the standard height is drawn standing there and measured, and the spot is believed only once two rolls land within 25% of each other. Up to four rolls are spent and the largest agreeing set wins, so an ordinary spot costs two renders and an unlucky one costs three.
Both halves of that are what the numbers required. One roll is not a measurement: on the same picture, same cell, different seed, one spot came back 50 px and then 245 px, and another 385 px then 147 px. Both numbers are plausible alone, so no floor and no plausibility threshold can separate them, and the small one was cached as that place's ground — a character placed there would have been drawn about a third the height of the bench. But two rolls are not enough either: rolled 14 times a good spot ran 552–728 px and another, 8 times, 348–578 px, so two rolls drawn at random disagree by more than 25% in 18% and 25% of pairs. Demanding that the first two agree refused about one build in five of places that measure perfectly well. Across every possible draw from those rolls — 364 and 56 of them — a third roll settled it every time.
A spot that still has no two rolls in agreement fails, naming every figure it drew, rather than caching one of them. A place whose spots cannot be measured refuses to calibrate instead of quietly calibrating wrongly. The labelled-grid locator is not the source of the variance: ten locates of the same spot on the same picture landed in the same cell ten times out of ten.
A limit worth knowing. The probe does not check that the place has ground in it: a close-up of a bench against a wall, with no floor in frame and nowhere a 170 cm person could stand, still measured repeatably — 14 rolls inside 1.32×. On a place that does have ground the figure's size is the place's and not the mask's (bands of 436, 686 and 936 px gave figures of 475, 494 and 489 px at the same foot row), but nothing yet tells the two cases apart.
What it knows so a caller need not: the edit order (dress, pose, prop, hair, expression); that a base is never accepted without passing the gates; that expressions are consistent across a wardrobe because each later one is rendered with a head crop of the first as reference; that a spot in a place is declared in words and then found in the picture, and carries the pose those words ask for, so nobody stands in the middle of a path at a bench's row; that a shot's pose is rendered as the character's own sprite first, so her proportions are hers — except for a posture the sprite pipeline draws badly, where the place's own measured probe figure stands in and she is painted over it; that a character drawn into a place is drawn from a painted edit of her sprite — the sprite's details in the scene's style — and composited by her own silhouette onto the background, so every character in a place shares its pixels exactly; that scale is measured rather than declared, in the picture where the model has perspective, and never asked of a client; that her pose and expression are judged against her placeholder and her sprite's face on side-by-side pairs, and an expression that drifts is repaired by a masked edit of her face; that a figure is relit to the place's own light and the remaining gap is gated against how mixed that light is (a lamp beside a night sky allows more than a flat afternoon); that an object an atmosphere or a dressing introduces is judged by the director model — a soft element is accepted, a hard fixture that does not belong is re-rolled; and that an expression pass fills the face out 5–13% in mean width, which no setting changes.
Broker HTTP API
Base: http://192.168.1.13:8080. Use the client unless you have a reason not to.
Data plane
| Route | Purpose |
|---|---|
POST /request | Submit. Body RequestIn; returns {ticket}. |
GET /request/{ticket}?wait=N | Long-poll (≤30 s). Returns TicketState: state = queued|ready|failed|cancelled, plus proxy_url, expires_in_sec, eta, reason. |
DELETE /request/{ticket} | Cancel. |
ALL /r/{ticket}[/{path}] | Data-plane proxy to the backend. All verbs. This is what proxy_url points at. WebSocket upgrades pass through on /r/{ticket}/ws[/{path}]. |
Sessions
| Route | Purpose |
|---|---|
POST /session | Open. Body SessionIn; returns {session_id, ttl_sec, plan}. |
GET /session/{sid} | Current placement plan. |
POST /session/{sid}/heartbeat | Keep alive. |
DELETE /session/{sid} | Release. |
Introspection & control
| Route | Purpose |
|---|---|
GET /healthz · GET / | Liveness · HTML dashboard. |
GET /status · GET /fleet · GET /voices | Snapshot · inventory · voices. See Host telemetry for the per-host fields both return. |
GET /loras | LoRA files under each ComfyUI host's models/loras (from the inventory scan) and ready_everywhere, the names every host has — poll it after a training job. |
POST /pin · /unpin · /force-config · /restart | Placement control. |
GET /stats · GET /stats.json | Usage aggregates for a browser · for tooling; see Usage statistics. |
POST /admin/enable | Global dispatch flag. |
POST /admin/unbench | Clear the circuit breaker's 600 s bench for {host?, service?} (omit both: everything). Use after fixing the launcher that got a service benched. |
GET /workflow/{aid} · POST /workflow/{aid}/render | ComfyUI workflow inspect / render. |
POST /agent/{host}/status · GET /agent/{host}/desired · /manifest | Agent control plane (agents only). |
Clients advertise their version via X-AI-Client; below the floor the broker
answers 426.
Host telemetry
Per-host fields on GET /status (and, where noted, GET /fleet). All are
nulled when a host's agent is down, so a dead box never reports frozen numbers as current.
| Field | Meaning |
|---|---|
gpus[].temp_c | Core temperature. The agent also logs any card at/over
80 °C to agent.log, rate-limited to one line per card per 5 min. |
gpus[].power_w · power_limit_w | Current draw and the enforced
cap. A limit below stock means someone applied nvidia-smi -pl. |
ram_mib_used · ram_mib_total | Physical memory. Counts
memory-mapped model pages, which are clean and reclaimable — a host at 87% because a model is mmap'd
is healthy. ram_mib_used is also what the host's RAM budget is measured from: the
reconciler records it on ticks when the host is running nothing of ours, and after five minutes of
such ticks sets the budget to total − the worst idle reading − 2,048 MiB. A host that
never goes idle, or whose agent reports no RAM, keeps what its manifest declares. |
commit_used_mib · commit_total_mib | Commit charge, and the number that predicts an out-of-memory death. Committed memory has been promised and cannot be reclaimed, only paged. Judge headroom by this, not by physical. |
services[].memory | {ws_mib, private_mib, procs} for that
service's whole process tree. private_mib is its committed share — this is what
attributes host memory to something the broker actually scheduled. |
commit_used_mib exists, and why a
GPU profile's host cost is not zero.Manifest schema
Fleet catalog: ~/ai/manifests/artifacts.json — the source of truth, projected
into per-host configs. Host budgets: ~/ai/manifests/hosts/<host>.json.
The broker hot-reloads on mtime change; no restart needed.
Artifact
| Field | Meaning |
|---|---|
id | Artifact id clients request. |
kind | llamacpp, embed, rerank, stt, tts, diarization, comfyui-engine, comfyui-workflow. |
model | {name, params, quant, context, vision, source, notes}. |
min_warm_sec | Load-cost floor (not a priority). |
autoscale | Set false to opt out of replica scaling. |
profiles[] | Run-profiles — see below. |
ComfyUI workflow artifacts
A comfyui-workflow artifact stores an API-format graph plus a
params map of {name: {node, input, default?, required?}}. Clients call
POST /workflow/{aid}/render to get the resolved graph, request the artifact to obtain a
ComfyUI proxy URL, then POST the graph to {proxy}/prompt. Each param targets
exactly one node input, so a value used by several nodes is fixed in the stored graph.
requires_nodes / requires_files gate the artifact to hosts that actually
carry them — a wrong path silently de-lists the whole workflow.
| Artifact | Purpose |
|---|---|
comfyui-flux1-q5 | Quality image tier. Flux.1 Q5_K_S GGUF, 28-step sampling with FluxGuidance. ~21 s per 1024×1024. Renders mechanical detail the speed tier smears. GGUF, so not restricted to Ada/Blackwell. |
comfyui-flux-unchained | Speed image tier — 8-step hybrid merge. Fast, lower fidelity. |
comfyui-appicon-flux1 | Square app-store icons. Generates on black, keys it out, crops to the subject, rescales to a fixed span, composites onto an exact-colour field — so size, opacity, corners, background colour, fill, centring and margins are true by construction. Accent repainted to an exact hex via a blue-minus-green channel mask (Flux drifts "indigo" toward magenta). |
comfyui-map-sdxl | Battlemap / sprite / fantasy-map generation (SDXL + LoRAs). |
comfyui-anime-sdxl / comfyui-anime-lora | Anime / visual-novel illustration on WAI-NSFW-Illustrious-SDXL v14 (booru-tag prompting; NSFW-capable by its own card). The -lora variant loads one LoRA by lora_name (a file under models/loras, see LoRA training). |
comfyui-anime-view | Location view amplifier. One base render of a room becomes another camera angle of the same room: Depth-Anything depth, reprojection to a moved camera (yaw, pitch, dolly, truck, fov), the scene fully regenerated from the warped depth under the xinsir depth ControlNet with the base as IP-Adapter reference and palette-matched to it (reusing the warped pixels was tried and judged misshapen). Keep moves small (yaw ±15°, dolly ±0.2) and chain from the new view for larger swings; caption the lighting when the views become a subject: scene training set. |
comfyui-anime-lora-ref | LoRA and reference image together: the LoRA supplies identity, the IP-Adapter reference (the character's canonical sprite) locks hair and outfit colours across seeds and lanes. Added after LoRA-only renders drifted between lanes. |
comfyui-anime-ref | Reference-image conditioning (IP-Adapter Plus SDXL). The image is passed inline as base64 (reference_b64) because ComfyUI uploads are host-bound. |
comfyui-sprite-inpaint | Masked repaint of an existing sprite (source_image, mask_image, denoise 0.4-0.7): only the masked region is regenerated and composited back over the original, so hair, body and outfit stay pixel-identical. The mask covers only what changes, such as a feathered ellipse from the eyebrow line to the chin inside the ears; a box repaints bangs and head shape. |
comfyui-anime-pose / comfyui-anime-pose-ref | OpenPose ControlNet (NoobAI) from a skeleton image; the -ref variant adds the IP-Adapter reference on top. |
comfyui-appicon-transparent | Transparent app icons: the construction of comfyui-appicon-flux1 with the subject repainted dark and the accent forced to an exact hex. Prompt for a violet disc whatever the brand colour: the accent mask is blue minus green, and a green prompt leaves it empty. |
comfyui-anime-depth | One room at any camera angle. A depth map the client renders from a 3D blockout of the room (near bright, far dark, greyscale) drives the xinsir depth ControlNet; the IP-Adapter reference keeps the base render's look and palette_strength (0-1) locks its colours. The reliable route; comfyui-anime-view is the single-image amplifier. |
comfyui-anime-inpaint / comfyui-sprite-rgba-inpaint | Masked img2img: only the masked region is resampled and composited back, and without mask_image the whole frame is repainted. The sprite variant fills the transparent area with the sprite lanes' flat grey and cuts the result out again with the same matte as every sprite. How to call them is under Image lanes: how to call them. |
comfyui-sprite-base-flat | A character sprite. Rendered opaque on a flat pale background and cut out by AIAnimeMatte: transparency never comes from the generator, because generated alpha dropped dark garments (feet in black kneehighs vanished). |
comfyui-sprite-qwen-edit / comfyui-sprite-qwen-edit-ref | Instruction edits of an accepted sprite: outfit, pose, hair, expression. The sprite is the conditioning, so nothing drifts between seeds. The -ref variant wires a second image (image2), such as a head crop of an expression's first render, so a later frame matches it. Same matte and cleanup as the base lane. |
comfyui-scene-plate | A place, drawn as one image so its lighting, occlusion and contact are real. The character orchestrator renders backgrounds with it and asks for nobody in the picture; a figure drawn in one is a probe, measured for scale and then discarded. Checkpoint, lora_name and lora_strength are parameters. |
comfyui-scene-inject / comfyui-scene-inject-masked | A character put into a picture: the picture is the latent, and her sprite and a crop of her head are references, so the place does not move between characters. The masked variant confines the sampler to mask_image, so one figure is replaced without touching anyone else in the shot. |
comfyui-scene-relight | The same place at another hour, with nothing moved. Redraws a background's light at full denoise while a canny ControlNet taken from that background holds its geometry, so shadows move and lamps light but fixtures may not shift. The instruct edit lane cannot do this: it starts from the base's latent but at denoise 1.0, so what comes back is a new picture of a similar place. control_strength, denoise and the canny thresholds are parameters. |
comfyui-scene-edit / comfyui-scene-edit-masked | The picture as latent and sole reference, changed by an instruction: light and weather, a figure added or removed, a dressing baked in. The masked variant changes pixels only inside mask_image. |
Two traps. The RemBG nodes are installed but have no onnxruntime backend; the node blocks
fetching u2net with no outbound DNS and wedges ComfyUI's single-threaded executor, after
which every job queues forever and looks like a hung sampler — recover with
POST /restart. No lane uses those nodes.
And /object_info stalls the server (a custom node calls the
HuggingFace API inside that handler); query /object_info/{NodeName} instead.
LoRA training (lora-train-sdxl)
A trainer-kind artifact: an HTTP sidecar on ai-150's 5090 that trains an SDXL LoRA
(kohya format, rank 16 on the UNet attention projections) from images a client sends inline, and
writes the file straight into ComfyUI's models/loras. Request it like any artifact and
talk to the proxy URL:
url = sb.request("lora-train-sdxl", environment="development", urgency="background", keep_alive=120)
r = httpx.post(f"{url}/train", json={
"name": "renai_alice", # -> models/loras/renai_alice.safetensors
"trigger": "alice_rn", # token prepended to every caption; use it in prompts
"images": [{"b64": "...", "caption": "1girl, solo, brown hair, twintails, school uniform"}, ...],
"steps": 800, "rank": 16, "resolution": 1024, "lr": 2e-4, # all optional (defaults shown)
"text_encoder_lr": 1e-4, # LoRA on both CLIP text encoders too (0 = UNet only)
"subject": "character", # or "scene": lighting variance is then reported, not dropped
"identity_tags": "brown hair, twintails, beige cardigan, ...", # stored in the LoRA, served by GET /loras meta
"palette_lock": True, "canonical": 0, # match every image's foreground tint/lighting to the first (reference) image
}, timeout=120).json() # {"job_id", "lora_name", "state", "queue_position"}
job = httpx.get(f"{url}/jobs/{r['job_id']}").json() # state queued|running|done|failed, step/total, loss, eta_sec
loras = httpx.get(f"{broker}/loras").json() # {"hosts", "ready_everywhere", "meta": {name: {trigger, identity_tags, subject}}}
Every submission is scored by a two-pass dataset check: an instant histogram pass (foreground, haze, colour
signature against the set, brightness, warmth) and, before training, the fleet's own vision lane describing
each image in a fixed schema and comparing it with the set's majority (style, hair, outfit; for scenes room
type and lighting), so a rejected image comes with a reason in words. POST /dataset-check on the
trainer runs both passes without training; outliers are dropped for subject: character and reported for
subject: scene. Prompt the LoRA as trigger + identity_tags from /loras
meta; never add colour words for other items that can bleed ("red hair ribbon" turned brown hair
crimson). Sprite lanes declare an output_check and the client re-rolls the seed when LayerDiffuse
returns an opaque frame. Location LoRAs: caption lighting and time of day so the trigger owns the room, not
the light. A single base render amplified through the reference lane yields a style of room, not one
room (operator-verified 2026-09-08): exact-room fidelity across angles needs a multi-view source — 6-10
views of the same room supplied by the client (its own scene blockout, or repeated inpainting from one
view) — trained with subject: scene. comfyui-anime-view is the fleet-side view amplifier for building that set from one base render.
Three placements: ai-150's 5090 (24,000 MiB) and both 3090s (20,000 MiB each, roughly half the step
rate; ai-150's second card listens on :11521), so three jobs train in parallel and a game's 7-10 LoRAs (characters plus locations) batch through
in an hour or two. Each trainer runs its own jobs serially. Requesting one evicts whatever large LLM holds
that card (the roleplay default relocates) for the job's duration. Keep keep_alive short: a running job
protects itself (the trainer reports busy, and a busy worker is never unloaded, evicted or drained), while a
long keep_alive only pins an idle trainer to the card afterwards — measured today: a 40-minute keep_alive
held a finished trainer on a 3090 that a client's model then could not use. When the job is done the LoRA
exists on ai-150; the broker box's ai-lora-mirror service copies new
*.safetensors between the hosts' models/loras every minute, and
GET /loras lists it under ready_everywhere once every ComfyUI host has it — from
then on comfyui-anime-lora and comfyui-anime-lora-ref accept it as
lora_name on any host. Locations train on the same pipeline (one base render of the room,
amplified into angles through comfyui-anime-ref, then trained). For scenery keep the adapter
light: weight 0.3 with weight_type "prompt is more important" (or 0.15-0.2 standard) —
the lane's character default of 0.6 burns rooms into neon oversaturation, and a LoRA trained on that set
learns the burn. Namespace the names,
e.g. renai-<game>-char-<id> and renai-<game>-loc-<id>
(letters, digits, - and _ survive sanitising; anything else becomes _). Caption only what varies between images (expression, pose, background), never the identity
traits the trigger should own — fully described images leave nothing for the trigger to learn (measured:
same 800 steps, wrong girl vs. the right one). Triggers must be non-words (rnchr_a1x, not
alice). Its ready probe (POST /selftest) runs real kernels on the GPU in the
training dtype (bf16, or fp16 with a grad scaler where bf16 GEMMs fail, as on ai-151), so a broken torch
build fails the probe rather than the first job. Sidecar source:
sidecars/lora_train/.
Audio generation (music & sound effects)
Distinct from speech. TTS/STT are their own artifacts
(kokoro, faster-whisper); generated music and sound effects run as
ComfyUI workflows on the burst tier. Two local paths, installed on ai-150 and ai-151 and
generation-tested on both, and a third foley model registered to compare against the first:
| Use | Model | Peak VRAM | Throughput | Length | Licence |
|---|---|---|---|---|---|
| World noises — birds, crowd, glass, foley | Stable Audio Open 1.0 | ~2.9 GiB | 20 s in 12 s | ≤ 47 s | Stability Community (free < $1M rev) |
| Short discrete foley — one animal call, one impact | TangoFlux | — | — | set by duration |
Non-commercial / research only |
| Music / long mood beds | ACE-Step | ~4.6 GiB | 20 s in 16 s | ~4 min | Apache-2.0 |
Request them by these artifact ids:
| Artifact | Use | Key params |
|---|---|---|
comfyui-foley-stableaudio | world noises / SFX | prompt required, negative,
seconds, seconds_total, seed, steps, cfg |
comfyui-foley-tangoflux | short discrete foley, compared against Stable Audio | prompt required, duration,
seed, steps, guidance_scale |
comfyui-music-acestep | music / mood beds | tags required, lyrics,
negative, seconds, seed, steps, cfg |
comfyui-foley-stableaudio, set seconds and
seconds_total to the same value. The duration is needed by two nodes, and a param
maps to exactly one node input, so there is no way to derive one from the other.import ai_fleet_client as sb, httpx
# 1. resolve the stored graph with your params
g = httpx.post("http://192.168.1.13:8080/workflow/comfyui-foley-stableaudio/render",
json={"params": {"prompt": "birds chirping at dawn, wind in leaves",
"seconds": 15.0, "seconds_total": 15.0}}).json()["graph"]
# 2. get a ComfyUI proxy URL — background so it queues behind live work
url = sb.request("comfyui-foley-stableaudio", environment="production",
urgency="background", keep_alive=600)
# 3. submit, then poll /history/{id} and fetch the file from /view
pid = httpx.post(f"{url}/prompt", json={"prompt": g}).json()["prompt_id"]
VRAM figures are measured, not estimated. At ~3–5 GiB the models are not the placement
constraint — the comfyui-engine profile is. Engine profiles exist only on
ai-150 gpu0/gpu1 and ai-151 gpu0. ai-9 has ComfyUI and an ACE-Step checkpoint but
no engine profile, so it cannot serve audio until one is added, and its GTX 1080 Ti (Pascal)
will be materially slower at diffusion. ai-151 gpu1 runs the speech stack (~7500 of 10000 MiB) and
also has no engine profile. ai-151 additionally carries ace_step_1.5_turbo_aio, which
ai-150 does not.
comfyui-foley-tangoflux is licensed for non-commercial research only. Its weights carry
the Stability AI Community License plus a WavCaps academic-only restriction, which is stricter than both other
audio lanes. It exists because Stable Audio follows prompts for short discrete foley weakly even at
steps 100 and cfg 7, and it takes only the params the model has.Choose by duration, not by type. Stable Audio for short one-shots; ACE-Step for long beds.
Nodes are native — CheckpointLoaderSimple + CLIPLoader{type: stable_audio}
+ ConditioningStableAudio + EmptyLatentAudio +
VAEDecodeAudio. Always give Stable Audio a negative prompt of
music, melody, singing, instruments, speech or it drifts into music.
urgency=background so it queues behind live work.
But note background deprioritises the requester, it does not protect
incumbents: a background audio job will still evict an idle model. Only standing
demand from a session keeps a model warm.Run-profile
| Field | Meaning |
|---|---|
host, placement, gpus[] | Where it runs and how it's laid out. |
vram_mib | {gpu_index: MiB}. Must be ≥ actual usage — under-declaring causes overcommit and OOM. |
cpu_mib | System RAM consumed (CPU-only or MoE offload). |
context | Tested context for this profile. Matched by min_context. |
port / ports[] | Listening port(s); must not collide within a config. |
launch[], cwd, env{} | Verbatim launch command (tokens like {share} substituted). If present, used exactly as written. |
endpoint, health_url | Backend address and health probe. |
skip_if_missing[] | Files that must exist or the variant is unavailable. |
exclusive_with[] | Services that cannot co-run. |
context requires changing the launch flag too. The
context field is what the scheduler matches; --ctx-size in
launch[] is what actually runs. Keep them in sync or the catalog lies.Operations
| Task | Command |
|---|---|
| Restart broker | systemctl --user restart ai-broker.service |
| Broker log | ~/ai/logs/broker.log |
| Agent + per-service logs | /mnt/ai-<host>/ai/logs/ |
| Deploy agents | scripts/deploy.sh |
| Manifest backups | ~/ai/manifests/backups/ |
| Edit catalog | Edit manifests/artifacts.json — hot-reloads, no restart. |
ai.wtray.com only /docs (this page) and /wheels/ are served; everything else redirects here. The broker is not at the public edge (operator, 2026-09-11). It was: every other path was reverse-proxied to it behind a comment claiming basic-auth with no directive to match it, so /status and /fleet handed host names, LAN addresses, GPU models and live session ids to anyone who asked. A gate could not be narrowed to the dashboard either, because the /r/ ticket proxies share the route, and IP-allowlisting is impossible here: the router SNATs inbound traffic, so Caddy sees one private source address for every external client. The route was removed rather than gated. Clients use http://192.168.1.13:8080 on the LAN, and the dashboard and ticket proxies live there.Troubleshooting
Requests time out repeatedly
Almost always model thrash: two models that can't co-reside are being requested alternately, and each turn pays a 15–60 s swap against a client timeout that's too short.
- Check
sb.status()["sessions"]— if 0, the app isn't using sessions. - Verify the working set can co-reside (capacity tables). Push small models to
placement="cpu". - Raise
timeout_secabove cold-load time — a 32B VLM takes 53 s+; 60 s is too tight.
"Upstream unreachable" from a ready ticket
The proxy URL outlived the model (15 min TTL vs 30 s keep_alive). Re-request, and hold the model with a
session or a longer keep_alive.
Ticket says ready but the proxy returns 410
A ticket whose proxy TTL has lapsed reports failed with proxy ticket expired; request a new ticket, and the proxy route rejects it with 410 ticket expired. Treat failed as "request
again", not as a fatal error.
The broker stops scheduling entirely (reconciler wedged)
Check broker.log for reconciler heartbeat stale ... — restarting
repeating with a non-advancing tick number. That means a tick is blocking longer than the
30 s watchdog, so the reconciler is killed and restarted before it ever completes. A mounted-share walk on the reconciler thread wedges it; if you add work to a tick, never let it touch a
mount synchronously — a degraded share can block for over a minute and turn a slow tick into a
permanent restart loop.
Everything queues and nothing dispatches
Check for a stray pin (sb.status() → pinned) and the global enable flag.
A pinned host serves only its pinned config.
A model won't load
Check the per-service log on the host share. After 2 consecutive failures to reach healthy, the circuit breaker benches it for 10 minutes. Common causes: OOM (VRAM under-declared), missing model file, bad split.
Silently smaller context than expected
You landed on a profile with a smaller tested window. Pass
requirements={"min_context": N} to make it fail loudly instead.
History
This reference describes the fleet as it is. Every change, with its measurements and reasons, is in CHANGELOG.md in the repository.
switchboard.wtray.com is now ai.wtray.com (the old name is gone, with no redirect). The pip package switchboard-client is now ai-fleet-client (import ai_fleet_client, release line 3.0.0); env vars SWITCHBOARD_* are now AI_*; the request header X-Switchboard-Client is now X-AI-Client; the exception is now AIError. The broker and orchestrators moved to 192.168.1.13 (192.168.1.33 is now only the public edge).