Skip to content
Open

Mlx #78

Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,9 @@ __pycache__/

# Per-machine bookkeeping written by the Cursor agent hooks, not configuration.
/.cursor/hooks/state/

# Benchmark scratch
tests/__pycache__/

# Local benchmark output (see tests/README.md for how to regenerate)
numbers.md
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ Never edit `desktop/docs/services-api.md` by hand. It is generated by

## Services (`services/`)

Thirteen Go binaries. Each component is its own module, with its tests beside its
Fourteen Go binaries. Each component is its own module, with its tests beside its
source and a `README.md` describing its JSON-RPC surface. Shared packages live in
`shared/`, and `tests/` holds cross-process tests that drive real binaries.
Prefer the Go source when a README disagrees with it.
Expand Down
131 changes: 129 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,10 @@ MIN_GO := 1.25
MIN_NODE := 25.5.0

.PHONY: help dev tools deps-go deps-node build build-binaries build-desktop \
build-services run check verify lint typecheck contracts headers \
headers-fix test test-desktop test-services clean
build-services macos-dev-local-network ab ab-context run check verify lint typecheck contracts headers \
headers-fix test test-desktop test-services clean \
mlx mlx-install mlx-status mlx-models mlx-pull mlx-delete mlx-set-port \
mlx-serve mlx-port mlx-ask mlx-ab mlx-reload-cost mlx-uninstall

help: ## List available targets
@printf 'Personal AI Router — development targets\n\n'
Expand Down Expand Up @@ -56,6 +58,98 @@ clean: ## Remove built binaries, bundles, and packages
done
@printf 'Removed build output. Dependencies in %s are untouched.\n' '$(NODE_MODULES)'

# ---------------------------------------------------------------------------
# MLX (Apple Silicon). See docs/mlx.mdx. Every target here is a thin wrapper --
# scripts/mlx.mjs holds the other end of nvpair-engine-manager's stdio JSON-RPC
# pipe, because that service has no HTTP control surface to curl.
# ---------------------------------------------------------------------------

# A default small enough to download in seconds and text-only, which matters:
# mlx_lm serves text models, so a vision model (Qwen3-VL, ...) will list in the
# catalogue and then fail to load. Override on any target: make mlx-pull MODEL=...
MODEL ?= mlx-community/Llama-3.2-1B-Instruct-4bit

# Where mlx_lm.server listens. Change it with mlx-set-port, which persists.
ENGINE_PORT ?= 8081

# The proxy prefers :8080 (mlx_lm.server's documented port) and falls back from
# :8090 when something else already holds it -- which is common. Ask the running
# process rather than assuming.
# -a is load-bearing: lsof ORs its selection options by default, so without it
# `-c mlx-proxy -iTCP` lists every listening socket on the machine and the first
# match is some unrelated process.
MLX_PORT = $(shell lsof -nP -a -c mlx-proxy -iTCP -sTCP:LISTEN -Fn 2>/dev/null \
| sed -n 's/^n.*:\([0-9][0-9]*\)$$/\1/p' | head -1)

mlx: build-services mlx-install ## Build PAIR and install the MLX engine (start here)
@printf '\nMLX installed. Next: make mlx-pull, then make mlx-serve.\n'

mlx-install: ## Install the MLX engine (uv + a virtualenv + mlx-lm)
node scripts/mlx.mjs install

mlx-status: ## Show installed / running / healthy / port for the MLX engine
node scripts/mlx.mjs status

mlx-models: ## List downloaded MLX models, marking the one resident in memory
node scripts/mlx.mjs models

mlx-pull: ## Download a model (MODEL=<hugging-face-repo-id>)
node scripts/mlx.mjs pull $(MODEL)

mlx-delete: ## Delete a model from the shared Hugging Face cache (MODEL=...)
node scripts/mlx.mjs delete $(MODEL)

mlx-uninstall: ## Remove the MLX engine and its virtualenv
node scripts/mlx.mjs uninstall

# Also the way to adopt a server you run yourself: point the engine at its port
# and `make mlx-serve` will route to that process rather than spawning its own.
mlx-set-port: ## Persistently move the MLX engine to a port (ENGINE_PORT=8089)
node scripts/mlx.mjs port $(ENGINE_PORT)

# The whole router, headless: the broker spawns discovery, the scheduler and all
# three engine proxies, then advertises this node; the script also starts the
# MLX engine, which the broker does not do on its own. Runs in the foreground --
# Ctrl-C to stop. `make run` is the same thing with the desktop app on top.
mlx-serve: ## Run the router and the MLX engine in the foreground (Ctrl-C to stop)
node scripts/mlx.mjs serve

mlx-port: ## Print the port mlx-proxy is listening on
@if [ -n '$(MLX_PORT)' ]; then printf '%s\n' '$(MLX_PORT)'; \
else printf 'mlx-proxy is not running. Start it with: make mlx-serve\n'; exit 1; fi

# The first request for a model is also what loads it, so this can take a while
# on a cold engine and be instant afterwards. That is the routing policy working,
# not a stall.
#
# MAX_TOKENS is generous because a thinking model spends its budget reasoning
# before it emits a single character of content: at 60 tokens a Qwen3.8 with
# thinking enabled returns finish_reason=length and an empty content field,
# which reads exactly like a broken route and is not one.
PROMPT ?= In one sentence, what does a router do?
MAX_TOKENS ?= 512

mlx-ask: ## Send a chat completion through mlx-proxy (MODEL=..., PROMPT=..., needs mlx-serve)
@if [ -z '$(MLX_PORT)' ]; then \
printf 'mlx-proxy is not running. In another terminal: make mlx-serve\n'; exit 1; fi
@printf 'routing through mlx-proxy on :%s\n\n' '$(MLX_PORT)'
@jq -n --arg m '$(MODEL)' --arg p '$(PROMPT)' --argjson t $(MAX_TOKENS) \
'{model:$$m, messages:[{role:"user",content:$$p}], max_tokens:$$t}' \
| curl -sS http://127.0.0.1:$(MLX_PORT)/v1/chat/completions \
-H 'Content-Type: application/json' --data-binary @- \
| (jq -r '.choices[0].message.content // .choices[0].message.reasoning_content // .' 2>/dev/null || cat)

# The two halves of the routing measurement in docs/mlx.mdx. mlx-ab needs no
# engine at all; mlx-reload-cost needs the engine installed.
mlx-ab: ## A/B the residency-preferring routing policy against the control arm
cd $(SERVICES)/mlx-proxy && go test -run TestRoutingPolicyAB -v .

mlx-reload-cost: ## Measure what one MLX model swap costs, in seconds
python3 $(SERVICES)/mlx-proxy/bench/reload_cost.py \
--server "$$HOME/Library/Application Support/Nvidia Corporation/Personal AI Router/engine-bin/mlx/venv/bin/mlx_lm.server" \
--model-a mlx-community/Llama-3.2-1B-Instruct-4bit \
--model-b mlx-community/Qwen2.5-0.5B-Instruct-4bit

tools: ## Report the required toolchain versions
@at_least() { printf '%s\n%s\n' "$$2" "$$1" | sort -V -C; }; \
missing=''; \
Expand Down Expand Up @@ -132,6 +226,39 @@ build-binaries: $(NODE_MODULES) ## Compile the Go service binaries into desktop/
build-desktop: $(NODE_MODULES) ## Build the Electron main, preload, renderer, and CLI bundles
cd $(DESKTOP) && npm run build

# macOS 15+ gates mDNS behind a per-app Local Network grant, and only offers it
# to a bundle carrying a usage string. The Electron npm installs has none, so a
# dev run cannot discover nodes and is never prompted. Re-run after `npm ci`.
# See docs/macos-local-network.md -- the grant follows the app that LAUNCHED the
# tree, so this alone is not enough from an editor's integrated terminal.
macos-dev-local-network: ## Make the dev Electron promptable for macOS Local Network access
./scripts/macos-dev-local-network.sh

# A/B the cluster: one node, the other, then both. MODEL_A/MODEL_B are the ids
# the router advertises -- a locally built model is addressed by absolute path,
# so the path IS the node selector (see tests/README.md).
MODEL_A ?= $(HOME)/models/Qwen3-VL-8B-Instruct-4bit
# The peer's model id. A locally built model is addressed by absolute path, and
# that path contains the OWNER's home directory -- so this is the peer's path,
# not yours. Override per run: make ab MODEL_B=/Users/<peer>/models/<model>
MODEL_B ?= $(error set MODEL_B to the peer node's model id, e.g. /Users/<peer>/models/<model>)
# Derived, not hardcoded: these are only labels in the report, and a hostname
# that gets renamed would otherwise leave the benchmark quietly mislabelling its
# own output.
NAME_A ?= $(shell scutil --get LocalHostName 2>/dev/null || hostname -s)
NAME_B ?= peer

ab: ## Benchmark node A, node B, then both (vars: MODEL_A MODEL_B NAME_A NAME_B)
python3 tests/ab_bench.py \
--model-a "$(MODEL_A)" --model-b "$(MODEL_B)" \
--name-a "$(NAME_A)" --name-b "$(NAME_B)" $(AB_ARGS)

ab-context: ## Find each node's usable context window
python3 tests/ab_bench.py \
--model-a "$(MODEL_A)" --model-b "$(MODEL_B)" \
--name-a "$(NAME_A)" --name-b "$(NAME_B)" \
--mode a --repeat 1 --max-tokens 8 --prompt-sizes 8 --context-probe

# The standalone bundle the TUI and the services installers use. The desktop app
# runs desktop/cli-bin instead, which build-binaries produces.
build-services: ## Stage the standalone services bundle in services/build/bin
Expand Down
Loading