Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

58 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

██╗     ███╗   ███╗███████╗██╗    ██╗██╗████████╗ ██████╗██╗  ██╗
██║     ████╗ ████║██╔════╝██║    ██║██║╚══██╔══╝██╔════╝██║  ██║
██║     ██╔████╔██║███████╗██║ █╗ ██║██║   ██║   ██║     ███████║
██║     ██║╚██╔╝██║╚════██║██║███╗██║██║   ██║   ██║     ██╔══██║
███████╗██║ ╚═╝ ██║███████║╚███╔███╔╝██║   ██║   ╚██████╗██║  ██║
╚══════╝╚═╝     ╚═╝╚══════╝ ╚══╝╚══╝ ╚═╝   ╚═╝    ╚═════╝╚═╝  ╚═╝

lmswitch

local LLM switcher · GGUF + vLLM

List and toggle local LLMs from per-model YAML configs.

Adding a model? See SKILL.md — a self-contained guide (for humans or AI agents) to authoring ai-models/*.yaml recipes: every key, the extra_args passthrough, and the GB10 gotchas. Point an agent at this folder and it can add a model from that doc alone.

lmswitch shows a table of every configured model (grouped by family) with its size, download state, port, and whether it's currently serving — and lets you start/stop them interactively. GGUF models run under llama.cpp (llama-server as a background process); vLLM models run in Docker. It waits for each model to actually become ready, refuses loads that would exceed free RAM, and keeps your coding agents' configs in sync — opencode, hermes, and grok — with whatever is serving.

Running lmswitch (the same wordmark above greets you):

  RAM    │ 122Gi total   33Gi used   89Gi available
  Models │ ~20.8G weights   1 / 3 loaded
  Disk   │ 113.1G   3 / 3 downloaded
  ● loaded   ○ stopped      ✓ downloaded   ✗ missing

   #  S  TYPE   NAME            SIZE  DL   PORT  DISPLAY
   Qwen ──────────────────────────────────────────────────────────────────────
   1  ○   gguf   qwen3-4b        2.3G  ✓   8085  Qwen3-4B
   2  ●   gguf   qwen3.6-35b    20.8G  ✓   8089  Qwen3.6-35B-A3B
   Nex ───────────────────────────────────────────────────────────────────────
   3  ○   gguf   nex-n2-pro     90.0G  ✓   8104  Nex-N2-Pro 397B-A17B (IQ1_M)

  Toggle # (space/comma separated, enter or q to quit):

Features

  • One table for everything — loaded () vs stopped (), downloaded () vs missing (), per-model size/port, and RAM / disk / loaded-count totals.
  • Four runtimes — GGUF via llama-server, safetensors/quantized via vLLM in Docker, and two ways to serve one model across two DGX Sparks over a CX7 link: vllm-dual (tensor-parallel) and llama-dual (GGUF split over llama.cpp RPC). Pick per model with runtime:.
  • Cluster view (optional) — with CLUSTER_HOSTS set, the table merges the other node's models with a HOST column (spark / gigabyte / dual) and toggling a peer's model delegates over SSH. Without it, nothing changes: single-box output and behavior are identical to previous versions.
  • Readiness-aware — after launch it polls the model's /v1/models endpoint and only reports Ready once it's actually serving (with a …loading progress heartbeat and crash detection), so the synced configs reflect reality, not guesses.
  • Pre-load RAM guard — refuses a start that would blow past available memory (overridable per-model with force: true), so a too-big model can't OOM-lock the machine.
  • Config sync — on every toggle / on / off / sync, the currently-serving models are written into your coding agents' configs: opencode (opencode.json), hermes (config.yaml), and grok (config.toml). Pick which targets are active during lmswitch init. See Config sync.
  • Optional systemd auto-restart per model via restart: on-failure.

Requirements

lmswitch targets Linux (it uses /proc/meminfo, ss, Docker --gpus, and systemd user units).

For You need
lmswitch itself Python 3.10+, curl, ss (iproute2). pyyaml is installed automatically as a dependency (a minimal built-in parser is used as a fallback if it's ever missing).
GGUF models A built llama.cpp with llama-server (a CUDA build for GPU offload). Default binary path: <lmswitch>/../llama.cpp/build/bin/llama-server — override per-model with llama_bin:.
vLLM models Docker + the NVIDIA Container Toolkit (--gpus all). Pulls the vllm/vllm-openai image.
restart: on-failure A running systemd user instance (systemctl --user).
config sync Any of opencode, hermes, grok (all optional — only configs that exist are synced).

Submodule

This repo includes a git submodule (ai-models/) with community-contributed YAML recipes for GGUF and vLLM models. The submodule lives at jvr0x/ai-models.

# Clone with recipes baked in:
git clone --recurse-submodules https://github.com/jvr0x/lmswitch.git

# If you already cloned (without --recurse):
cd lmswitch
git submodule update --init

If you only need the recipes (no lmswitch code), clone the submodule repo directly: https://github.com/jvr0x/ai-models

Install

lmswitch is a Python package exposing a lmswitch console-script entry point (lmswitch = "lmswitch.cli:main"). The cleanest install is as an isolated CLI tool with uv (or pipx) — it keeps lmswitch's deps out of your system Python and sidesteps the externally-managed-environment (PEP 668) error on Debian/Ubuntu:

# from the repo dir (e.g. ~/utils/lmswitch)
uv tool install -e .        # editable; puts `lmswitch` on your PATH (~/.local/bin)
uv tool update-shell        # one-time: ensure uv's bin dir is on $PATH
lmswitch init
Other install methods
pipx install -e .                                  # same idea, via pipx
pip install --user -e . --break-system-packages    # plain pip --user (overrides PEP 668)
pip install -e .                                    # inside an activated virtualenv

init asks where your models live (writes ai-models/.lmswitch), creates the ai-models/ config dir, and asks which sync targets to enable (opencode / hermes / grok — only the ones whose configs it finds). It does not reinstall the command: if a lmswitch console script is already on your PATH (from the step above) it leaves it alone; only if none is found does it drop a small launcher in ~/.local/bin pinned to the current interpreter. Ensure ~/.local/bin is on your $PATH.

Upgrade after pulling changes with uv tool upgrade lmswitch (an editable install picks up code edits automatically); remove with uv tool uninstall lmswitch.

Getting started

  1. Install the requirements above.
  2. lmswitch init — set your models directory (default ~/models).
  3. Download a model into that directory (see Where to get models).
  4. Create a config — lmswitch add <name>, or copy a template from examples/ into ai-models/<name>.yaml.
  5. lmswitch → type the model's number to start it. It loads, waits until the endpoint answers, prints Ready on port <port>, and syncs your enabled configs (opencode / hermes / grok).
  6. Hit it: curl localhost:<port>/v1/models.

Usage

lmswitch                  # interactive: show the table, then type model #s to toggle
lmswitch list             # just print the table (read-only)
lmswitch on  <name|#>     # start a model
lmswitch off <name|#>     # stop a model
lmswitch sync             # regenerate enabled configs from currently-serving models
lmswitch add  <name>      # create a model config interactively
lmswitch serve <name>     # run a model in the foreground (used by systemd)
lmswitch init             # bootstrap ai-models/, .lmswitch, and sync targets
lmswitch -h, --help       # show help
lmswitch -v, --version    # print the version

In the interactive prompt you can toggle several at once, space/comma separated: 8 9 24. A toggle blocks until the model is ready (or its ready_timeout elapses); Ctrl-C aborts cleanly — the model keeps loading detached, so re-run lmswitch or lmswitch sync to pick it up once it's up.

Model configs (ai-models/<name>.yaml)

The filename (minus .yaml) is the model's id, its served-model-name, and — for vLLM — its container name (vllm-<id>). model: is a path relative to your models directory. Fully-commented templates live in examples/llama-gguf.yaml and examples/vllm.yaml.

Common keys

Key Default Meaning
runtime llama llama (GGUF) or vllm (Docker)
model path to the .gguf file (llama) or model dir (vLLM), relative to the models dir
port 8081 OpenAI-compatible server port
ctx 65536 context length
display_name <name> label in the table / synced configs
ready_timeout 600 (vLLM) / 300 (llama) seconds to wait for readiness
force false bypass the pre-load RAM guard
restart on-failure → run under a systemd user unit

llama (GGUF) keys: gpu_layers (99), threads (12), batch (1024), ubatch (512), alias, mmproj, llama_bin, and fit — default off, which skips llama.cpp's auto memory-fit step (it aborts in cudaMemGetInfo on some CUDA builds, e.g. GB10/Blackwell); set fit: none to omit the flag entirely on older llama.cpp builds that don't support -fit.

vLLM keys: gpu_memory_utilization (0.15), image, tool_call_parser, reasoning_parser, trust_remote_code, max_num_seqs, extra_args, and more — see examples/vllm.yaml for the full list.

Cluster mode: two Sparks (runtime: vllm-dual)

Serves one model tensor-parallel (TP=2) across two DGX Sparks over the CX7 link, using vLLM's native multi-node launcher (--nnodes 2 + --headless worker — no Ray). The head container runs on this node and exposes the API; the worker container is started on the peer over SSH and computes rank 1 of every forward pass. lmswitch on/off manages both nodes; a failed head tears the worker back down.

Weights can live once on either node: give each node its own path to the same directory (model_path / worker_model_path) — typically the owning node's ~/models/... and the peer's NFS mount of it. Both are mounted at a canonical /model inside the containers.

vllm-dual keys (on top of the vLLM keys above):

Key Default Meaning
worker_host SSH alias of the peer node (passwordless)
master_addr this node's CX7-link IP
worker_ip peer's CX7-link IP (its VLLM_HOST_IP)
master_port 25000 torch distributed rendezvous port
nccl.ifname CX7 interface (also pins GLOO_SOCKET_IFNAME — Gloo otherwise grabs arbitrary NICs)
nccl.hca RDMA device for NCCL_IB_HCA
nccl.gid_index NCCL_IB_GID_INDEX
model_path weights dir on this node (may be an NFS mount)
worker_model_path model_path weights dir on the peer
worker_env env overrides applied only to the worker container
gpu_memory_utilization 0.80 per node — each holds its TP shard
ready_timeout 1800 TP=2 loads are slow on first boot

Set enforce_eager: false in dual YAMLs when the profile relies on cudagraph capture — the single-node default is eager. And stop local models first: a loaded model on either node shrinks vLLM's memory budget there. See examples/vllm-dual.yaml.

Cluster mode: GGUF across two Sparks (runtime: llama-dual)

Serves one GGUF split across both nodes with llama.cpp's RPC backend: this node runs llama-server, the peer runs rpc-server (started over SSH), and --tensor-split decides how many layers land on each. No containers, no NCCL. RDMA over the CX7 link is auto-negotiated when both llama.cpp builds have -DGGML_RPC=ON -DGGML_RPC_RDMA=ON.

Weights live once, on this node: the head reads the GGUF and pushes the peer's share over the link, so nothing has to be copied or NFS-mounted. The peer caches what it receives (rpc_cache), so restarts skip the re-push.

llama-dual keys (on top of the llama keys above):

Key Default Meaning
worker_host SSH alias of the peer node (passwordless)
worker_ip peer's CX7-link IP — the RPC endpoint
rpc_port 50052 port rpc-server listens on
tensor_split even share per device, e.g. "0.45,0.55"
rpc_cache true peer caches received tensors under ~/.cache/llama.cpp/rpc
rpc_threads peer's CPU threads (rpc-server -t)
worker_rpc_bin ~/utils/llama.cpp/build/bin/rpc-server binary on the peer

tensor_split follows llama-server's device order — local CUDA devices first, then one RPC<n> per --rpc endpoint. Confirm with llama-server --list-devices --rpc <worker_ip>:<rpc_port> after a llama.cpp upgrade; reversing it silently loads the wrong share onto the wrong box. Both nodes must run the same llama.cpp commit.

Readiness waits on /health, not just the port: llama-server binds the port before loading and answers 503 meanwhile, which for a cross-node load is minutes of "ready" that isn't. lmswitch off kills the peer's rpc-server even when the head is already gone — a stranded one keeps its whole share resident.

Cluster visibility: add CLUSTER_HOSTS="<ssh-alias>" (comma-separated) to ai-models/.lmswitch on each node. lmswitch list then merges the peers' tables (via ssh <peer> lmswitch list --json) under a HOST column, and toggling a peer's model — by name or table number — delegates over SSH. Unreachable peers are skipped silently; the local table never breaks.

Where to get models

Models come from Hugging Face into your models directory; each config's model: path is relative to it.

pip install -U "huggingface_hub[cli]"

# GGUF (llama.cpp) — e.g. Unsloth / bartowski quants; grab the .gguf file(s)
hf download unsloth/Qwen3-4B-GGUF Qwen3-4B-Q4_K_M.gguf \
  --local-dir ~/models/unsloth/Qwen3-4B-GGUF
#   → model: "unsloth/Qwen3-4B-GGUF/Qwen3-4B-Q4_K_M.gguf"

# vLLM (safetensors, incl. FP8 / NVFP4 quants) — grab the whole repo into a dir
hf download nvidia/Qwen3.5-MoE-...-NVFP4 --local-dir ~/models/nvidia/qwen3-...-nvfp4
#   → model: "nvidia/qwen3-...-nvfp4"

Good sources: unsloth/, bartowski/, ggml-org/ for GGUF; the upstream model repos and nvidia/ (FP8 / NVFP4) for vLLM. Multi-shard GGUF (*-00001-of-0000N.gguf) is detected automatically — point model: at the first shard.

How it works

  • GGUFllama-server is launched as a detached background process; its PID and full output go to ai-models/running/<name> and …/<name>.log. -fit off is passed by default (see fit: above).
  • vLLMdocker run -d --name vllm-<name> --gpus all --network host …; any stale/exited container of the same name is docker rm -f'd first to avoid a name conflict.
  • Readiness → after launch it polls http://localhost:<port>/v1/models until it answers (Ready on port <port>), the process/container dies (✗ … exited during startup + a pointer to the log / docker logs), or ready_timeout elapses (WARNING).
  • RAM guard → before launching, free RAM (MemAvailable from /proc/meminfo) is compared to an estimate: gpu_memory_utilization × total for vLLM, on-disk weight size × 1.3 for GGUF. If short, the start is refused unless force: true.
  • Config sync → each enabled target gets the currently-serving models, all pointing at http://<SPARK_HOST>:<port>/v1: opencode one provider per model, hermes the active model + a custom_providers entry per model (so they show in /model), grok one [model.<id>] table per model. SPARK_HOST is a constant in lmswitch.system.io (spark-8912.local) — change it if your host differs. See Config sync.

Config sync

lmswitch keeps your coding agents' configs honest: on every on / off / toggle / sync it rewrites the currently-serving models into each enabled target, every endpoint pointing at http://<SPARK_HOST>:<port>/v1. Your agent always sees the models that are actually up — right ports, right names — with no hand-editing and no calls to a model that isn't loaded. SPARK_HOST is a constant defined in the package (lmswitch.system.io.SPARK_HOST); set it to your serving host.

Targets are chosen during lmswitch init and stored as SYNC_OPENCODE / SYNC_HERMES / SYNC_GROK in ai-models/.lmswitch (only configs that exist on disk are touched; a target with no config is skipped). Each shapes its own file:

  • opencode~/.config/opencode/opencode.json gets one provider per serving model. If ~/.local/share/opencode-export/ exists, a copy is written there too, so a remote client (a laptop/Mac over Tailscale, LAN, or a Samba mount) can pick up the same config and point straight at the serving host.
  • hermes~/.hermes/config.yaml. Hermes runs one active model, so the model: block is set to the serving model and kept sticky (only switched when the current one stops); a vision model (id containing vl) is wired into auxiliary.vision. Every serving model is also registered under custom_providers: (with discover_models: false, so the picker doesn't live-probe and hang) — that's what makes them all selectable from hermes' /model. Custom providers you added by hand (pointing at other hosts) are preserved. Because discovery is off, /model reflects the last sync — re-run lmswitch sync (or just toggle) after starting a model to refresh the list.
  • grok~/.grok/config.toml gets one [model.<id>] table per serving model; all your other grok settings ([cli], [ui], marketplace, the [models] default, …) are left untouched.

Run lmswitch sync to regenerate on demand — handy after a detached load finishes.

Architecture

lmswitch is a small Python package with deliberate module boundaries. Runtimes are pluggable: adding a new backend (sglang, TGI, …) means writing one file that subclasses BaseRuntime and registering it — no changes to the CLI, sync, or loader code.

lmswitch/
├── __main__.py          # `python -m lmswitch`
├── cli.py               # arg parsing, table rendering, interactive TUI, commands
├── sync.py              # opencode / hermes / grok config sync
├── models/
│   └── loader.py        # discover & parse ai-models/*.yaml → model dicts
├── runtimes/            # how a model is started / stopped / probed
│   ├── base.py          #   BaseRuntime ABC + RuntimeRegistry
│   ├── llama.py         #   GGUF via llama-server (detached background process)
│   ├── vllm.py          #   vLLM via Docker
│   ├── systemd.py       #   restart: on-failure → systemd user unit
│   └── wait.py          #   readiness polling
└── system/
    ├── io.py            # paths, constants (SPARK_HOST), YAML, family rules
    ├── checks.py        # port / docker / process-state detection
    └── memory.py        # /proc/meminfo + pre-load RAM guard

A toggle flows: cli resolves the name → runtimes.start_model runs the RAM guard (system.memory) and dispatches to the matching BaseRuntimeruntimes.wait polls until the endpoint answers → sync rewrites the enabled agent configs. All filesystem state lives under ai-models/ (configs, .lmswitch, and running/ PID files); the LMSWITCH_DATA_DIR env var overrides that root (used by the tests).

Development & tests

cd ~/utils/lmswitch
uv venv                            # create .venv from pyproject (Python >=3.10)
uv pip install -e .                # the package (pulls in pyyaml)
uv pip install pytest              # test runner
uv run pytest -q                   # run the whole suite (57 tests)

Run a single file / test:

uv run pytest tests/test_sync.py -q
uv run pytest tests/test_sync.py::test_regen_hermes_keeps_running_default_sticky -q
File Covers
tests/test_cli.py name/index resolution, rendering, command dispatch, init
tests/test_llama_cmd.py llama-server command construction
tests/test_vllm_and_abort.py vLLM start, readiness, RAM guard, Ctrl-C, opencode sync
tests/test_sync.py config sync to opencode / hermes / grok (selection, idempotency, round-trip)
tests/test_process_lifecycle.py start → detect-running → stop lifecycle

All tests are pure unit tests — subprocess / Docker / curl / ports are stubbed and configs are written to temp dirs (via LMSWITCH_DATA_DIR), so they run anywhere (no GPU, no models, no Docker) and never touch your real configs.

License

Apache-2.0 © 2026 jvr0x.

About

List and toggle local LLMs (GGUF via llama.cpp, vLLM via Docker) from per-model YAML configs

Topics

Resources

Stars

6 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages