Switchboard — Complete Reference
Switchboard routes AI work to a fleet 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.33:8080. - Agents — one per GPU host. Poll desired state, launch/stop services, report health +
nvidia-smi. - Client —
switchboard-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 switchboard_client as sb, httpx
url = sb.request("qwen3-30b-a3b-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 switchboard-client --extra-index-url https://switchboard.wtray.com/wheels/
--extra-index-url, not --index-url.
--index-url replaces PyPI, and this wheelhouse only carries
switchboard-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 |
|---|---|---|
SWITCHBOARD_BROKER | http://192.168.1.33:8080 | Broker base URL |
SWITCHBOARD_STT_ARTIFACT | faster-whisper | Artifact the speech helpers resolve to |
SWITCHBOARD_TTS_ARTIFACT | kokoro | Artifact the TTS helpers resolve to |
426 Upgrade Required
to older clients. Current line: 2.0.2.Artifacts
An artifact is the concrete thing you ask for — a model id with its quantisation
(qwen3-vl-32b-q4 vs qwen3-vl-32b-q8 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. |
url = sb.request("qwen3-vl-32b-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-30b-a3b-q4", "environment":"production","urgency":"interactive","keep_alive":120},
{"artifact":"qwen3-vl-32b-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-30b-a3b-q4")
vlm = s.request("qwen3-vl-32b-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.
Artifact catalog
Live as of this document's generation. Authoritative source is sb.fleet().
| Artifact | Kind | Params | Host | Placement | Context | Cost | Port |
|---|---|---|---|---|---|---|---|
comfyui-engine | comfyui-engine | — | ai-150 | gpu0 | — | 16000 MiB | 8188 |
comfyui-engine | comfyui-engine | — | ai-150 | gpu1 | — | 16000 MiB | 8189 |
comfyui-engine | comfyui-engine | — | ai-151 | gpu0 | — | 16000 MiB | 8188 |
pyannote | diarization | — | ai-151 | gpu1 | — | 2000 MiB | 7862 |
qwen3-embedding-4b-q8 | embed | 4B | ai-150 | cpu | 32768 | 10000 MiB RAM | 11500 |
qwen3-embedding-4b-q8 | embed | 4B | ai-151 | gpu0 | 32768 | 7500 MiB | 11500 |
gemma-4-26b-a4b-q4 | llamacpp | 26B (MoE, 4B active) | ai-151 | gpu0 | 16384 | 17000 MiB | 11504 |
mistral-small-3.2-24b-q4 | llamacpp | 24B | ai-150 | gpu1 | 32768 | 19000 MiB | 11503 |
mistral-small-3.2-24b-q4 | llamacpp | 24B | ai-151 | gpu0 | 32768 | 19000 MiB | 11503 |
qwen3-30b-a3b-q4 | llamacpp | 30B (MoE, 3B active) | ai-150 | gpu0 | 32768 | 23000 MiB | 11502 |
qwen3-30b-a3b-q4 | llamacpp | 30B (MoE, 3B active) | ai-151 | gpu0 | 32768 | 21000 MiB | 11502 |
qwen3-8b-q4 | llamacpp | — | ai-9 | gpu0 | 32768 | 8500 MiB | 11500 |
qwen3-vl-32b-q4 | llamacpp | 32B | ai-150 | gpu0 | 32768 | 28000 MiB | 11505 |
qwen3-vl-32b-q4 | llamacpp | 32B | ai-151 | gpu0 | 32768 | 22000 MiB | 11505 |
qwen3-vl-32b-q8 | llamacpp | 32B (dense) | ai-150 | all | 49152 | 47000 MiB | 11506 |
bge-reranker-v2-m3-q8 | rerank | 568M | ai-150 | cpu | 8192 | 1500 MiB RAM | 11501 |
faster-whisper | stt | — | ai-151 | gpu1 | — | 4000 MiB | 7861 |
kokoro | tts | — | ai-151 | gpu1 | — | 1500 MiB | 7852 |
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) | RTX 3090 — 22,000 MiB | 80,000 MiB |
ai-151 | RTX 3090 — 22,000 MiB budget (24,576 physical) | RTX 2080 Ti — 10,000 MiB | 24,000 MiB |
ai-9 | GTX 1080 Ti — 10,000 MiB | — | 24,000 MiB |
The large-model conflict
The big text and vision models each require a gpu0, and there are only two gpu0s in the fleet. They cannot co-reside on the same host:
| Pair | Host | Arithmetic | Fits? |
|---|---|---|---|
qwen3-30b-a3b-q4 + qwen3-vl-32b-q4 | ai-150 gpu0 | 23,000 + 28,000 = 51,000 > 30,000 | No |
qwen3-30b-a3b-q4 + qwen3-vl-32b-q4 | ai-151 gpu0 | 21,000 + 22,000 = 43,000 > 22,000 | No |
qwen3-vl-32b-q4 + qwen3-embedding-4b-q8 | ai-151 gpu0 | 22,000 + 7,500 = 29,500 > 22,000 | No |
qwen3-30b-a3b-q4 + qwen3-embedding-4b-q8 | ai-150 (gpu0 + cpu) | 23,000 VRAM + 10,000 RAM | Yes |
The working three-model layout
A text + vision + embedding app can run fully resident — but only with embeddings pushed to CPU. This layout is verified working:
ai-150 / gpu0 -> qwen3-30b-a3b-q4 # 23,000 of 30,000 MiB
ai-150 / cpu -> qwen3-embedding-4b-q8 # system RAM, no VRAM
ai-151 / gpu0 -> qwen3-vl-32b-q4 # 22,000 of 22,000 MiB
Without placement="cpu" on the embeddings, the broker may park them on
ai-151/gpu0, which blocks the VLM entirely and forces a swap on every loop turn.
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, SwitchboardError on failure/cancel,
UpgradeRequired if the client is too old.
session() / Session
sb.session(requires, *, priority=0.0, requester=None, broker=None) -> Session
sb.subscribe(...) # alias — a session IS a subscription
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.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, strength, in_flight, 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.) |
strength | (−tested_context, −vram) — prefers the stronger run-profile. Added 2026-07-31; before this, ties fell through to alphabetical hostname, so the same artifact id could land on a far smaller context window by accident. |
in_flight | Least loaded. |
host | Deterministic final tiebreak (stable scheduler). |
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. |
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. |
proxy_ttl_sec | 15 min | How long a ready ticket's proxy URL stays usable. |
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.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.
Broker HTTP API
Base: http://192.168.1.33: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. |
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. |
POST /pin · /unpin · /force-config · /restart | Placement control. |
POST /admin/enable | Global dispatch flag. |
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-Switchboard-Client; below the floor the broker
answers 426.
Manifest schema
Fleet catalog: ~/switchboard/manifests/artifacts.json — the source of truth, projected
into per-host configs. Host budgets: ~/switchboard/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. |
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 switchboard-broker.service |
| Broker log | ~/switchboard/logs/broker.log |
| Agent + per-service logs | /mnt/ai-<host>/switchboard/logs/ |
| Deploy agents | scripts/deploy.sh |
| Manifest backups | ~/switchboard/manifests/backups/ |
| Edit catalog | Edit manifests/artifacts.json — hot-reloads, no restart. |
/docs (this page) and /wheels/ are public.
The dashboard and broker API at switchboard.wtray.com are password-gated, because
/fleet and /status expose host names, LAN addresses and GPU models. LAN clients
should use http://192.168.1.33:8080 directly — no password.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.
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.
Change log
2026-07-31 (later 3)
- Big models were landing on the weaker GPU. With both hosts idle, a consumer's vision
model consistently ran on the RTX 3090 while the RTX 5090 held a smaller text model.
Cause: placement is greedy and was ordered by arrival — the text model was
requested first, correctly won the 5090, and by the time the larger vision model was
requested the 5090 was taken, so
evictpushed it to the 3090. With two large models and twogpu0s one must use the 3090; the defect was that arrival order, not size, decided which. Fix: co-queued work is assigned hardest-to-place first (largest VRAM footprint), and GPU claims are now tracked during the pass so the function stops double-assigning every large model to the single strongest host. Verified live: VLM on ai-150, text on ai-151.
Caveat: this only applies when requests are queued together — i.e. inside a session. Sequential blockingrequest()calls put one item in the queue at a time, so the broker never sees both and cannot co-plan.
2026-07-31 (later 2)
- Dead hosts reported stale services as "running".
/statusand the dashboard built their per-service view from the last status the agent POSTed, which freezes when an agent dies — so an offline host kept showing its last-known services asrunning: true, making it look like the fleet was still using that box. The scheduler was never affected (it readscurrent_config, cleared on death, andhost_can_serve()rejects dead agents).running/healthyare now forced false for a dead agent and the entry is flaggedstale.
Signature:agent_alive: falsenext to a non-emptyrunninglist. Confirm real placement by issuing a request and seeing which host loads it.
2026-07-31 (later)
- Scheduler — warmth inversion fixed.
warmthwas host-level (0 if h.current_config else 1), so any host running anything out-ranked an idle host — even for unrelated work on a different GPU. Effect: an idle RTX 5090 lost to an RTX 3090 that merely had a small audio service on its second GPU. Now request-specific:warmthis 0 only if the host already runs/desires a service that serves this request. Idle and unrelated-busy now tie, sostrengthdecides and the better GPU wins. Verified live: with ai-151 holding kokoro on gpu1 and ai-150 idle, the 32B VLM now lands on ai-150 (5090). 105/105 tests pass.
2026-07-31
- Scheduler: added a capability tie-break (
strength) to host and replica selection, ranking by tested context then VRAM. Sits belowevict/warmth, so anti-thrash behaviour is unchanged. 105/105 tests pass. - ai-151
qwen3-30b-a3b-q4: context 16384 → 32768. Measured 15,390 MiB of 24,576 at the new size — fully on GPU, 9.2 GB free. - ai-151
qwen3-embedding-4b-q8: declared VRAM 5,500 → 7,500 MiB. A clean measurement showed real cost 6,632 MiB — it was under-declared, risking overcommit. - Fleet-wide VRAM audit of all 13 GPU profiles; results in
manifests/backups/vram-audit-2026-07-31.json. Several profiles are over-declared (safe direction; costs packing density only) and were deliberately left alone.
2026-07-30
- ai-151
qwen3-vl-32b-q4: context 8192 → 32768. The 8192 cap was a stale over-declaration, not a hardware limit — measured 19,294 MiB of 24,576 at 32768. - switchboard-client 2.0.2: fixed
transcribe_stream,tts_say*,transcribeandSession.request, which requested legacy"stt"/"tts"capability tokens and omitted the now-mandatoryenvironment/urgency, so they raised before making any HTTP call.