Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
cff4503
netpick: admit DNS names as dialable node addresses
cguldogan Sep 4, 2026
2846596
engine-manager: add the vLLM manifest and a served-model setting
cguldogan Sep 4, 2026
ef3259e
node-info: report this node's service map on /v1/node-info
cguldogan Sep 4, 2026
430d9ac
manual nodes: a PAIR peer added by address behaves like a discovered one
cguldogan Sep 4, 2026
6c7ef4d
Route vLLM through the OpenAI-compatible proxy
cguldogan Sep 4, 2026
79ef58c
tests: cross-process gate for a PAIR peer added by address
cguldogan Sep 4, 2026
6914681
desktop: Add node creates the node, then pairs with it
cguldogan Sep 4, 2026
b1235fc
docs: running PAIR across a Tailscale tailnet, and version bumps
cguldogan Sep 4, 2026
3f6ec5b
desktop: surface vLLM as a third engine
cguldogan Sep 4, 2026
f432ce1
desktop: pin that a manual PAIR peer renders as a discovered one
cguldogan Sep 4, 2026
e6f8b80
tests: wait for the broker's fixed pairing port before pairing
cguldogan Sep 4, 2026
4a49cc9
Document vLLM and bump the components it changed
cguldogan Sep 4, 2026
215e344
desktop: show "Model to serve" on the local node only
cguldogan Sep 4, 2026
a4b96a3
Document vLLM in the remaining hand-maintained docs
cguldogan Sep 4, 2026
cd09a8c
Merge branch 'feat/tailscale-remote-nodes' into feat/vllm-tailscale
cguldogan Sep 4, 2026
d676885
Merge feat/vllm-engine into feat/vllm-tailscale
cguldogan Sep 4, 2026
5f75190
engine-manager: add the SGLang manifest
cguldogan Sep 8, 2026
f38df00
services: gofmt the alignment drift ahead of the SGLang work
cguldogan Sep 8, 2026
439f46a
docs: how PAIR treats a vLLM instance that spans several machines
cguldogan Sep 4, 2026
3927423
Route SGLang through the OpenAI-compatible proxy
cguldogan Sep 8, 2026
507199a
Document SGLang and bump the components it changed
cguldogan Sep 8, 2026
f88595b
desktop: surface SGLang as a fourth engine
cguldogan Sep 8, 2026
513e1a1
Document SGLang in the hand-maintained user docs
cguldogan Sep 8, 2026
dc024b6
sglang: probe /get_model_info, never /health
cguldogan Sep 8, 2026
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
33 changes: 21 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,12 @@ requests can be routed to eligible nodes according to engine availability,
model availability, and current workload.

PAIR is useful for concurrent local workloads such as multi-agent applications.
Prompts and responses are intended to remain on the local network when every
configured client, model source, engine, and node is local.
Prompts and responses are intended to stay on the network you route them over —
your local network, or an encrypted overlay such as a Tailscale tailnet that you
configured yourself — when every configured client, model source, engine, and
node is one of yours. Refer to
[Running PAIR across a Tailscale tailnet](docs/remote-networks.mdx) for nodes
that are not on the same local link.

> PAIR routes each independent request to one node. It does **not** pool GPU
> memory, combine GPUs into a larger logical GPU, shard one model across
Expand All @@ -38,7 +42,7 @@ one, and both report live GPU and memory use throughout.
| **Architectures** | x64 and arm64 on all three. Windows on ARM is experimental. |
| **Installers** | Windows `.exe`; Linux `.deb`; macOS `.dmg`. On other Linux distributions, [build from source](docs/building.mdx). |
| **Mixing nodes** | Windows, Linux, and macOS nodes can all be paired with each other |
| **Inference engines** | Ollama and LM Studio |
| **Inference engines** | Ollama, LM Studio, and — on Linux — vLLM and SGLang |

**PAIR running on a machine does not mean an engine will.** PAIR itself runs on
any supported Windows, Linux, or macOS machine. Each engine sets its own requirements
Expand Down Expand Up @@ -100,9 +104,10 @@ you want by its full filename instead.
status there.

- **Get an engine running.** On the node's card, open **Engine settings** and
select **Install** next to Ollama or LM Studio. PAIR downloads and sets the
engine up for you, so nothing needs to be in place beforehand. If PAIR already
found an engine you installed yourself, start that one instead.
select **Install** next to Ollama, LM Studio, or (on Linux) vLLM or SGLang.
PAIR downloads and sets the engine up for you, so nothing needs to be in place
beforehand. If PAIR already found an engine you installed yourself, start that
one instead.

![The Install engines dialog with Ollama downloading, reporting progress as it installs.](docs/assets/onboarding/engine-lifecycle/01-engine-installing.png)

Expand Down Expand Up @@ -149,8 +154,9 @@ The reply is ordinary OpenAI-shaped JSON, abbreviated here:
}
```

If you changed a port, or you are using LM Studio rather than Ollama, copy the
URL from **Endpoints → API endpoints** instead of assuming the one above.
If you changed a port, or you are using LM Studio, vLLM, or SGLang rather than
Ollama, copy the URL from **Endpoints → API endpoints** instead of assuming the
one above.

That is a single machine working. To route across machines, pair a second one
from **Settings → Cluster** and repeat the engine and model steps there. The
Expand Down Expand Up @@ -224,19 +230,22 @@ Each entry assumes the ones before it.
4. **[Terminal interface](docs/terminal-interface.mdx)** — the same tasks from a
terminal, for a machine with no desktop environment. Skip it if every machine
you run has a desktop.
5. **[Troubleshooting](docs/troubleshooting.mdx)** — worth skimming once before
5. **[Remote networks](docs/remote-networks.mdx)** — read this if your machines
are joined by a VPN or overlay network rather than a local link: discovery
does not cross one, so you add the peer by name instead.
6. **[Troubleshooting](docs/troubleshooting.mdx)** — worth skimming once before
you need it, so you know where the diagnostics live. Alongside it,
**[Known issues](docs/known-issues.mdx)** lists the significant limitations we
are already aware of, and
**[Collecting and sanitizing logs](docs/log-collection.mdx)** covers preparing
a log you can share.
6. **[Architecture](docs/architecture.mdx)** — the process model, how a request is
7. **[Architecture](docs/architecture.mdx)** — the process model, how a request is
routed, and where the trust boundaries are. Read this before changing
anything, or if you want to know why PAIR behaves the way it does.
7. **[Building and running](docs/building.mdx)** — prerequisites, building from
8. **[Building and running](docs/building.mdx)** — prerequisites, building from
source, running the services without the desktop application, and writing
your own client against the JSON-RPC API.
8. **[Developer guide](docs/developing.mdx)** — read this before contributing:
9. **[Developer guide](docs/developing.mdx)** — read this before contributing:
where the code lives, how a change travels through the layers, and the
conventions the project enforces.

Expand Down
20 changes: 16 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ PAIR is a LAN-first, multi-process application:
JSON-RPC 2.0 over the broker's standard input and output.
- The broker supervises Go workers and relays their control-plane methods and
notifications.
- Discovery and selected metadata endpoints operate on the local network.
- Discovery and selected metadata endpoints operate on the local network, or on
an encrypted overlay network the operator configured, such as a Tailscale
tailnet. Discovery itself is multicast and does not cross one; a node on the
far side is added by address.
- Ollama-compatible and OpenAI-compatible local HTTP proxies carry inference
traffic and may route a request to another paired node.
- The cluster manager uses a six-digit PIN to bootstrap trust. Cluster-scoped
Expand Down Expand Up @@ -76,17 +79,26 @@ open relay for inference to anything that can route to it. Run an application on
a node and use that node's local endpoint. Exposing an engine to the network
directly is outside PAIR and is the operator's decision and risk.

### Local Network Is a Trust-Relevant Boundary
### The Network You Route Over Is a Trust-Relevant Boundary

PAIR discovers nodes and exposes service metadata on the LAN. Some discovery
enrichment and node-information traffic can use plain HTTP. Treat an untrusted
Wi-Fi, shared office network, compromised router, and hostile local process as
potentially adversarial. Network segmentation and host firewall rules remain
the operator's responsibility.

Nodes joined by an encrypted overlay network such as a Tailscale tailnet, rather
than by a local link, are the same boundary reached a different way. The overlay
carries its own encryption and its own admission policy, and PAIR's inter-node
traffic is certificate-pinned mutual TLS in either case; what changes is that
everyone admitted to the overlay is on this boundary, so who may join it is the
operator's decision. Refer to
[Running PAIR across a Tailscale tailnet](docs/remote-networks.mdx).

“Local-first” describes the intended topology. It does not prove that no data
leaves the machine or LAN. Inference engines, model catalogs, update systems,
applications, and user configuration may contact external services.
leaves the machine, the LAN, or an overlay you configured. Inference engines,
model catalogs, update systems, applications, and user configuration may contact
external services.

### Pairing PIN Is a Bootstrap Convenience

Expand Down
1 change: 1 addition & 0 deletions desktop/docs/services-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@
| `engine:remote-stop` | request (we call) | ✅ yes |
| `engine:remote-unload-model` | request (we call) | ✅ yes |
| `engine:restart` | request (we call) | ⚠️ not called |
| `engine:set-model` | request (we call) | ✅ yes |
| `engine:set-port` | request (we call) | ✅ yes |
| `engine:start` | request (we call) | ✅ yes |
| `engine:status` | request (we call) | ✅ yes |
Expand Down
16 changes: 10 additions & 6 deletions desktop/docs/services-backend.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ broker supervises every worker and relays its control plane.
| ------------------------- | --------------------------------------------------------- |
| `nvpair-ui-broker` | Worker supervision and relay |
| `ollama-proxy` | Ollama-compatible routing proxy with cluster-mTLS ingress |
| `lmstudio-proxy` | LM Studio routing proxy with cluster-mTLS ingress |
| `lmstudio-proxy` | OpenAI-compatible routing proxy (LM Studio, vLLM, SGLang) with cluster-mTLS ingress |
| `nvpair-node-scanner` | Discovery and node announcement |
| `nvpair-node-info` | Node metadata and telemetry |
| `nvpair-manual-nodes` | User-managed node entries |
Expand Down Expand Up @@ -90,8 +90,10 @@ engine, workload, cluster, and error relays. The bridge then emits renderer push
events from backend notifications.

Connector readiness follows the broker contract: `app:ready` establishes the
service connection, while Ollama and LM Studio proxy readiness remains an
asynchronous capability signal. Personal AI Router waits up to the canonical
service connection, while the Ollama and OpenAI-compatible proxy readiness
signals remain asynchronous capability signals. The OpenAI-compatible proxy
fronts LM Studio, vLLM and SGLang together, so one `lmstudio-proxy:ready` records
the port for all of them. Personal AI Router waits up to the canonical
startup deadline in `src/shared/constants/modular-runtime.ts` for
`app:ready`; an outright failure or stalled broker startup is surfaced in
Settings > Service with retry and log access. If a stalled broker reports ready
Expand Down Expand Up @@ -266,9 +268,11 @@ LAN-reachable. Each node fronts its engine with its `ollama-proxy` /
`lmstudio-proxy`, whose LAN ingress is gated by cluster mTLS: only a pinned
cluster member can send it work. Discovery advertises the promoted **proxy**
port (never the engine port), and the broker hands the private loopback engine to
the local proxy via `node/set-local-backend`. Every cluster-scoped worker derives
its own membership from the cluster directory continuously, so a proxy's mTLS
ingress tracks a create, join, or leave with no worker restart. Loopback
the local proxy via `node/set-local-backend`, which is keyed per engine so the
one OpenAI-compatible proxy can hold an LM Studio, a vLLM and an SGLang backend
at once. Every cluster-scoped worker derives its own membership from the cluster
directory continuously, so a proxy's mTLS ingress tracks a create, join, or leave
with no worker restart. Loopback
plaintext requests from local clients are unaffected. This is entirely
backend-owned; Personal AI Router only reflects the advertised proxy port and does not
implement any of the transport security.
Expand Down
21 changes: 18 additions & 3 deletions desktop/docs/services-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ history.
| Manual nodes | Complete with local persistence | Broker owns probing and proxy registration; Electron persists entries for replay |
| Ollama routing | Complete | Broker relay and backend scheduler drive proxy routing |
| LM Studio routing | Complete | Parallel broker relay and scheduler path |
| vLLM routing | Complete on Linux | Shares the LM Studio relay, the OpenAI-compatible proxy, and the same scheduler path |
| SGLang routing | Complete on Linux | Shares the LM Studio relay, the OpenAI-compatible proxy, and the same scheduler path |
| Local engine lifecycle | Complete | Install, start, stop, uninstall, update, and port configuration |
| Remote engine lifecycle | Partial | Remote install, start, stop, status, and model pull are supported |
| Engine models | Partial | Core list, pull, load, unload, and supported delete actions are wired |
Expand Down Expand Up @@ -102,7 +104,9 @@ they survive worker restarts.
Both text-engine proxies are broker-owned and cluster-aware:

- `ollama-proxy` serves the Ollama-compatible surface;
- `lmstudio-proxy` serves the LM Studio/OpenAI-compatible surface.
- `lmstudio-proxy` serves the OpenAI-compatible surface for every
OpenAI-compatible engine — LM Studio, vLLM and SGLang share the one listener,
and a node record can advertise `lm`, `vl` and `sg` at the same port.

Routing precedence is manual selection, scheduler priority, then deterministic
proxy ordering. Personal AI Router leaves proxies in automatic mode.
Expand Down Expand Up @@ -197,8 +201,19 @@ Personal AI Router uses:
- Ollama `run_model`, `unload_model` (`keep_alive: 0`), and `delete_model`;
- LM Studio `load_model`, `unload_model`, and `delete_model` (`remove_path`).

Both engines expose Load, Eject, and Delete in the model manager when the
backend action exists. Keep-alive / expiry controls remain unsupported.
Ollama and LM Studio expose Load, Eject, and Delete in the model manager when
the backend action exists. Keep-alive / expiry controls remain unsupported.

vLLM and SGLang have no model operations at all: each declares only
`list_models`, `loaded_models`, and `chat`, because such a process serves exactly
one model chosen at launch and it neither downloads nor deletes weights on
request. Both capability entries therefore set `hasDeleteModel: false`,
`hasEject: false`, `hasExpiry: false`, and `modelOpsWhenStopped: false`, and add
`hasServedModel: true` — the model to serve is a *setting* (`engine:set-model`),
not a model action, and changing it restarts the engine. That setting takes
either a Hugging Face model id or the path of a local model directory: vLLM's
`--model` and SGLang's `--model-path` both accept both, and `/v1/models` reports
back whichever was given, verbatim.

LM Studio's `delete_model` declares `restart_after`, so the engine manager
restarts a running LM Studio once the files are removed — its `/v1/models` is
Expand Down
58 changes: 56 additions & 2 deletions desktop/src/electron/service-bridge/empty-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
MODULAR_ENGINE_LIFECYCLE_CALL_TIMEOUT_MS
} from '@/shared/constants/modular-runtime'
import getErrorString from '@/shared/utils/get-error-string'
import { createStructuredLogger } from '@/shared/utils/log'
import { getEngineHubModels } from '@/electron/model-hub'
import { getModularSupervisor } from './modular-supervisor'
import {
Expand All @@ -23,7 +24,12 @@ import {
import type { ProxyEngine } from './modular-state'
import type { JsonObject, JsonValue } from './json-rpc-subprocess'
import { emptyInvite, parseClusterNodes, parseInvite, parseNodeIdentity } from './cluster-json'
import { removeManualNodeEntry, resolveManualNodeKey } from './manual-nodes-store'
import {
addManualNodeEntry,
manualPortsToWire,
removeManualNodeEntry,
resolveManualNodeKey
} from './manual-nodes-store'

type BridgeHandler<C extends WsInvokeChannel> = (
payload?: WsInvokeRequest<C>
Expand All @@ -34,6 +40,8 @@ type BridgeHandlerMap = {
}

// The broker allows pairing exchanges up to 30 seconds; keep the UI bridge outside that deadline.
const log = createStructuredLogger('service-bridge')

const CLUSTER_PAIRING_CALL_TIMEOUT_MS = 35_000

function wait(ms: number): Promise<void> {
Expand Down Expand Up @@ -507,6 +515,26 @@ function routeEngineManagerCommand(payload: WsInvokeRequest<'engine:command'>):
case 'toggle':
void toggleLocalEngine(engine, payload.engineType)
break
case 'setServedModel':
// vLLM and SGLang serve one model per process, so the model is a
// start-time setting rather than a model operation. engine:set-model
// persists it as a manifest override — the same mechanism
// engine:set-port uses — and restarts a running engine onto it. It
// is engine-agnostic: the engine id travels with the call, so no
// branch here needs to know which engine declared a served model.
// Local only: the backend exposes no remote served-model control.
supervisor.sendProcess(
'broker',
'engine:set-model',
{ engine, model: payload.model ?? '' },
failAction('set the served model', {
nodeId: payload.nodeId,
engineType: payload.engineType,
modelName: payload.model
}),
true
)
break
case 'setPorts':
// Both ports persist on this node: the engine HTTP server port via
// engine:set-port (manifest override), the proxy port via the broker's
Expand Down Expand Up @@ -798,6 +826,32 @@ async function handleClusterInviteNode(
): Promise<Invite> {
if (!payload) return emptyInvite()

// Add the address as a manual node BEFORE inviting it.
//
// On a network that carries no multicast — a Tailscale tailnet, a routed
// subnet — nothing is ever discovered, so a peer that is only paired stays
// invisible: no record arrives for it, ever. Adding it first also gives the
// operator the one piece of feedback that matters when an address was typed
// by hand: the node appears with its hardware as soon as it answers, whether
// or not the pairing that follows succeeds.
const supervisor = getModularSupervisor()
if (supervisor.hasProcess('broker')) {
const entry = addManualNodeEntry(payload.ipAddress, payload.ports)
const params: JsonObject = { address: entry.address, name: entry.name }
const wirePorts = manualPortsToWire(entry.ports)
if (wirePorts) params.ports = wirePorts
try {
await supervisor.callProcess('broker', 'node/add', params)
} catch (err) {
// Non-fatal: the entry is persisted and replayed on the next start,
// and the invite below is what the operator asked for.
log.warn({
sublevel: 'manual-nodes',
message: `Failed to add manual node ${entry.address}: ${getErrorString(err)}`
})
}
}

// cluster-manager auto-founds a solo cluster on the first invite while
// unclustered (under inviteMu, with invite-created provenance). Do not
// pre-call cluster:create here: parallel Invites used to race concurrent
Expand All @@ -813,7 +867,7 @@ async function handleClusterInviteNode(
'cluster:invite-node',
{
address: payload.ipAddress,
port: MODULAR_CLUSTER_MANAGER_PORT
port: payload.ports?.cluster ?? MODULAR_CLUSTER_MANAGER_PORT
},
CLUSTER_PAIRING_CALL_TIMEOUT_MS
)
Expand Down
Loading