Skip to content

Add SGLang as a fourth inference engine - #50

Open
cguldogan wants to merge 24 commits into
NVIDIA:mainfrom
cguldogan:pr/sglang-engine
Open

cguldogan wants to merge 24 commits into
NVIDIA:mainfrom
cguldogan:pr/sglang-engine

Conversation

@cguldogan

Copy link
Copy Markdown

Description

PAIR routes to Ollama, LM Studio and (with #9) vLLM. This adds SGLang as a fourth engine on the same footing: detected or adopted, started and stopped, advertised over discovery, routed to, scheduled, and shown in the desktop and terminal interfaces.

User-visible outcome:

  • SGLang appears in Engine settings on Linux with install, start, stop, restart, uninstall and port configuration. On macOS and Windows it is listed but not installable, because SGLang publishes no GPU build there.
  • An already running SGLang on port 30000 (a sglang serve --model-path … or the lmsysorg/sglang container) is adopted rather than restarted. That is the normal path.
  • Like vLLM, an SGLang process serves one model chosen before it starts, so the existing Model to serve field applies. It accepts a Hugging Face id or a local model directory; the id SGLang then advertises is its --served-model-name, which defaults to the --model-path verbatim, so a local directory shows up under its own path. There are no pull, load, eject or delete operations.
  • SGLang shares the existing OpenAI-compatible proxy with LM Studio and vLLM: one listener, one port, every engine's models in a single /v1/models, each request routed to the local engine that owns the requested model and each workload tagged with that engine.
  • Manually added bare hosts are probed for SGLang on 30000, told apart from other OpenAI-compatible servers by GET /get_model_info, a route only SGLang serves. The scheduler ranks SGLang nodes with the same node-wide load signal as the other engines.
  • A tensor-parallel SGLang that spans several machines (--nnodes N) is adopted on its head rank only; worker ranks open no port and correctly show no engine. The docs say so, next to the equivalent vLLM note.

One SGLang-specific finding worth knowing: on current SGLang builds GET /health runs a real forward pass and takes about a second (the same as /health_generate), while /get_model_info answers in well under a millisecond. PAIR probes an engine's ready route often (status polls, model-list requests from every peer, the loaded-model watcher, the health loop), so every SGLang probe here uses /get_model_info. With /health a node with several peers serialized behind the engine's operation mutex and its model endpoint stopped answering; the last commit explains the measurement.

Related: #24 asks for adoption of OpenAI-compatible backends and names SGLang as one; this covers the SGLang case the way #9 covers vLLM, through the one shared OpenAI-compatible proxy rather than a new binary.

Scope

Included: engine manifest (services/nvpair-engine-manager/manifests/sglang.json, Linux amd64/arm64, uv venv install of sglang[all]), discovery key sg, the OpenAI-compatible proxy's engine table (resolution order LM Studio, vLLM, SGLang), broker advertise loop and manual-node bridge, the bare-host probe leg with its /get_model_info disambiguator (and the matching guard so the vLLM leg never claims an SGLang), scheduler engine list, TUI label, desktop engine type, capabilities, icon and per-engine attribution, readable display of path-like model ids, docs, and tests for each of those.

Excluded: a managed port facade for SGLang (it keeps 30000 and is never moved), model download or deletion, any Windows or macOS install path, and authentication headers for an SGLang behind a token (#26).

Stacked on #9 and #10. SGLang needs #9's OpenAI-proxy generalization and served-model machinery, and #10's per-service port overrides for manually added nodes, so this branch is based on the merge of those two. Until they land, the diff here shows all three; the SGLang-only change is https://github.com/cguldogan/Personal-AI-Router/compare/d676885...pr/sglang-engine (8 commits, 61 files). No breaking IPC change: node/add-manual and node/remove-manual simply accept sglang as a third engine value.

Validation

Environment: macOS 15 arm64, Go 1.27, Node 23; two DGX Spark (GB10, aarch64) nodes running a real two-rank SGLang group as the engine under test.

export PATH=/opt/homebrew/bin:$PATH
cd services && ./build.sh                       # 13 binaries, product 0.93.0
for m in shared lmstudio-proxy nvpair-ui-broker nvpair-manual-nodes nvpair-job-scheduler nvpair-tui nvpair-engine-manager nvpair-node-scanner; do
  (cd $m && gofmt -l . && go vet ./... && go test ./... -count=1)
done
cd services/tests && go test ./... -count=1     # cross-process interop, 331 s
cd desktop && npm run typecheck && npm run lint && npm run test:unit && npm run service-contracts:check && npm run dead-code:check
node scripts/spdx-headers.mjs                   # 896 checked, 0 missing
git merge-tree --write-tree upstream/main pr/sglang-engine   # clean

All pass: gofmt clean and vet clean in every module; 7 of the 8 Go modules fully green; the interop suite passes with 0 skips; desktop typecheck clean, lint 0 errors (one pre-existing prettier warning in a file this change does not touch), 243 unit tests in 42 files pass; service contracts and dead-code checks pass; every commit is signed off. The one failure is nvpair-engine-manager TestUninstallTerminatesRunningInstance, which fails identically on the unmodified base tree on this host (it cannot resolve the image path of the process holding a port; the same failure is noted in #9) and is unrelated.

A note for reviewers reading the series in order: the docs commit says SGLang readiness uses GET /health, and the final commit changes that to GET /get_model_info after measuring the cost against a real instance. Each message is accurate for its own commit; the end state is /get_model_info everywhere.

End-to-end against a real SGLang (lmsysorg/sglang, --tp 2 --nnodes 2, head rank on 30000):

  • The head node adopts it and shows SGLang installed, running and healthy; the worker rank shows no engine, as intended.
  • The node advertises sg at its proxy port, its /v1/models carries the model attributed to sglang, and a chat completion sent to a peer's PAIR proxy routes over the cluster to the head node and answers.
  • The same chat sent to the head node's own proxy answers locally, and to a macOS desktop node's proxy routes to the head node; the Jobs panel shows the run tagged SGLang.
  • Eight concurrent model-list requests on the head node complete in about 10 ms total after the /get_model_info change; with /health they took 1 s each and serialized.

Screenshots of the node card (SGLang adopted on the head node) and of the Jobs panel (a run tagged SGLang) follow in a comment.

Risk

  • Additive IPC/HTTP only: a new discovery key, a new accepted engine value, new sglang_* manual-node status fields and a sglang port override. Consumers that ignore unknown keys and fields are unaffected; the desktop is updated in the same change.
  • The probe route matters for SGLang specifically: reverting to /health reintroduces the one-second-per-probe cost described above. The manifest, the broker probe and the user doc all say why.
  • Model ids from SGLang can be filesystem paths (leading slash, several segments). Nothing in the Go services parses model ids, they are compared by equality only, and this is covered by tests using such an id; the desktop renders the last path segment for display and keeps the raw id on the wire.
  • No packaging, migration or data changes. The bundled manifest is Linux-only, so nothing changes on Windows or macOS beyond a listed, not-installable engine.

Checklist

  • I have read the Contributing Guidelines.
  • Every commit is signed off (git commit -s), certifying the Developer Certificate of Origin.
  • New or existing tests cover the change.
  • Relevant documentation is updated.
  • I checked the diff, changed filenames, and commit messages for credentials, private data, internal URLs, internal issue identifiers, and generated artifacts.
  • I recorded the validation commands and results above.
  • I bumped any affected component in services/versions.json, and described user-visible changes above so they reach the release notes.

cguldogan and others added 24 commits September 4, 2026 05:08
A node reachable only over an encrypted overlay may have no address a peer
can assume. A Tailscale node's MagicDNS name outlives every literal it
holds, and its IPv4 literal is a 100.64/10 CGNAT address that netpick
already scores below a LAN address -- correctly, as a ranking.

The exclusion was elsewhere: Candidates, RankRemote and IPsFromTXT all
gated entries on net.ParseIP, so a node whose only address was a name
produced an empty candidate list and Primary answered "". Every consumer
had grown its own ad-hoc fallback for that, and they did not agree.

Admit names through one shared test (Hostname / dialable) and score them
between the public and private classes: a name re-resolves, so it survives
the renumbering that strands a literal, but a LAN peer's own literal is
still the better answer when both exist. The per-caller fallbacks that
existed only to recover a .local name are now a second reading of the same
source, so they collapse to the one case they still answer -- the node
published no address at all and only its discovery host name is left.

CGNAT, IPv6 ULA and virtual-adapter demotion stay rankings. Nothing here
promises reachability; a consumer that must connect still confirms by
connecting.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
vLLM serves exactly one model per process, so its launch command names
the model. Add a {model} placeholder resolved from a new runtime.model,
a runtime.extra_args tail appended to runtime.args, and the persistent
engine:set-model / one-shot engine:start {model} setters that choose it.
Starting an engine whose launch template needs {model} with none
configured now fails with guidance instead of spawning.

The bundled vllm.json is Linux-only (vLLM ships no Windows or macOS GPU
build), process mode, port 8000, /health readiness with a 30-minute
budget for the first weight download, and the OpenAI list/loaded/chat
actions. Adoption is the primary path: process mode reconciles an
already-serving listener even when detect misses, so a user's own
`vllm serve` or the vllm/vllm-openai container is picked up.

engine:set-port and engine:set-model share one override file, so both
now read-modify-write it rather than replacing it with a single-key
delta, and the file is unlinked only once no override remains.

allowedPlaceholders had been doing double duty as the set an
engine:action caller may not supply. Split out
reservedActionPlaceholders so {model} stays caller-suppliable on a
model action while remaining a valid manifest placeholder.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
A node's {service: port} set existed in exactly one place a peer could read
it: the ni=/ol=/lm=/em=/ec=/... keys on its mDNS record. Multicast does not
cross a routed or overlay network, so a peer on a Tailscale tailnet can
learn that a node exists -- someone typed its address -- and nothing about
what it runs. It cannot tell a PAIR node from a bare Ollama box, and it has
no way to find the node's engine manager or its promoted proxy ports.

node-info is the one inter-node surface deliberately kept plain, which
makes it the one place such a peer can ask. Carry the set there.

The broker owns the set (it assigns and re-assigns those ports) and already
holds it as the registration cache it replays to the scanner. It now
projects that same cache into a nodeinfo:set-services push, on node-info
spawn and on every register/unregister, so the HTTP answer and the mDNS
record are one derivation rather than two.

The set is always sent whole: a service that stopped is expressed by its
key being absent, exactly as an unregister is on the record. Absent from
the response entirely means the parent has not pushed yet -- not "this node
runs nothing", which a peer would act on.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
Adding a PAIR node by address did not work, and could not have. The prober
asked its 11434 and 1234 in plaintext, but on a PAIR node those ports carry
the proxy facades, which refuse plaintext from anything but loopback -- so
every probe answered 403 and the peer read as having no engines. If it had
been bridged anyway, the proxies would have dialed it plain (a manual
candidate carries no cluster principal, so HasPin is false and routing falls
to the manual arm) into the same refusal. Manual nodes only ever worked for
the bare Ollama / LM Studio box they were built for.

That box still exists and is unchanged. What is new is the second kind of
manual node: a peer that is a PAIR node and is simply on the far side of a
network that carries no multicast, such as a Tailscale tailnet. It is not a
special case, it is an ordinary peer with no discovery between here and
there, so it is made into one.

- The prober asks node-info FIRST, and its answer decides everything else.
  A service map identifies a PAIR node, and such a node is never probed on
  its engine ports again.
- A PAIR node's models come from its engine manager over pinned mTLS, the
  same fetch the scanner makes for a discovered peer. No pin, no models:
  before pairing the node still appears with its hardware.
- The broker synthesizes the directory record the scanner would have
  produced and pushes it into the discovery relay, so both proxies, the
  scheduler, engine-manager's remote operations, the workload relay and the
  errors peer sync all see a pinned peer. It carries the peer's cluster
  principal, which is what gets it dialed over mTLS instead of 403'd.
- It refuses to synthesize a record for this host itself (by identity, so
  the overlay name for this machine is caught too) or for a node the
  scanner already owns, and it withdraws only records it put there.
- The raw-engine bridge is withdrawn for a PAIR node rather than left
  alongside, so there is one route to a node and not two.

node/add also gains per-service port overrides, and now rejects a
"host:port" address with the reason instead of accepting an entry that can
never be reached: service ports are appended to that value.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
vLLM speaks the same OpenAI HTTP surface as LM Studio, so it needs the
same router rather than a new binary: lmstudio-proxy becomes the
OpenAI-compatible proxy for every such engine. It subscribes to lm and
vl, each routing candidate records which engine it represents, and
workloads are tagged with that engine instead of a package constant.

A peer advertises lm and vl at the same value — its one proxy port — so
a dual-engine peer still projects to a single routing target whose
per-engine model attribution says which engine owns what. A manual node
is the exception: the user supplies an engine's own address and LM
Studio and vLLM sit on different ports, so node/add-manual and
node/remove-manual now carry an engine and the manual overlay is keyed
by (engine, node). The reachability cache is keyed the same way, while
scheduledOn and node selection stay keyed by the bare node id.

node/set-local-backend becomes per-engine, so one node may run both
engines at once and clearing one leaves the other routing. Cluster
ingress picks the local engine that actually serves the requested model
— read from the engines themselves, since this node's own discovery
entry can lag — and answers GET /v1/models by merging both local
engines, which a single forward would have hidden half of.

Broker: runAutoAdvertiseVLLM registers vl at the OpenAI proxy's port and
hands the engine's loopback port to that proxy. There is no managed
facade for vLLM, so the loop carries none of LM Studio's facade
handling. Manual nodes gain a vllm_* triple probed on 8000, with vLLM's
own /version as the disambiguator from any other OpenAI server. The
scheduler ranks vllm alongside the others, and the TUI's second proxy
panel now names both engines it fronts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
Node A is a real broker with its real workers; node B is a real
cluster-manager and a real ollama-proxy in front of a fake engine, plus the
two HTTP surfaces a peer exposes. A pairs with B over the real PIN exchange
addressed by nothing but an address and a port -- no nodeId, because A never
discovered B and never will -- and is then told about it with one node/add.

It asserts the three things "behaves like a discovered peer" has to mean:
B appears in A's directory as a trusted, clustered peer with the model list
A read from B's engine manager over cluster mTLS; A's proxy routes an
inference request to B over cluster mTLS and B's engine serves it; and A
opens zero plaintext connections to B's engine ports, which is the
regression this change exists to fix.

Two real brokers cannot share one loopback -- every broker-owned port is a
compiled-in constant -- so B is a stub peer on ephemeral ports, reached
through node/add's new per-service port overrides. Everything the
assertions turn on is real: the identities, the pins, the pairing, the
proxy, and the transport choice.

Also adds the PairNode hysteresis the test implies. Recomputing it per
probe meant one missed node-info answer -- three seconds, routine across an
overlay network -- probed the peer's proxy facades in plaintext, blanked
its service map and withdrew it from every consumer for a cycle. It now
holds across a failure episode on the same counter discovery uses, and
reverts past it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
Add node only ever sent an invite. On a LAN that was enough, because
discovery brought the peer in anyway. On a network that carries no
multicast it is not: nothing writes manual-nodes.json, no record ever
arrives, and a peer could pair successfully and stay invisible forever.

The dialog now records the address as a manual node and tells the broker
about it before inviting. That order is the useful one: the node appears
with its hardware as soon as it answers, which is the only feedback an
operator gets that a hand-typed address is right, and a pairing that fails
leaves something on screen to retry against instead of nothing.

The field is an address or a host name, with the guidance a VPN user needs
-- use the MagicDNS name, it re-resolves -- and a Service ports disclosure
for a node whose services do not sit on the defaults. The overrides are
persisted with the entry, because the replay after a restart is the only
thing that re-creates it: without them a node on non-default ports comes
back unreachable.

The two spellings of those ports (camelCase here, snake_case on the wire)
meet in one projection, narrowed rather than cast.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
A new page for the case the rest of the documentation did not cover: two
machines that can reach each other but have no local link between them.
What a tailnet changes (no discovery, CGNAT addresses, MagicDNS names), the
add-by-name → pair → verify walkthrough, a Tailscale ACL grant that opens
exactly the eight ports PAIR needs between tagged nodes and says what each
is for, and the four failures that are specific to this setup -- node with
no models, 403 from a peer, a name that will not resolve, and a node that
was found and went quiet.

Linked from the README reading order, from the empty-discovery section of
Troubleshooting (where an empty list is the expected result, not a fault),
and from the Manual Nodes section of Architecture, which now describes the
two kinds of manual node rather than one.

The README and SECURITY.md said prompts remain on the local network. They
now say the network you route them over -- your local network, or an
encrypted overlay you configured -- which is what was always true and is
now reachable. SECURITY.md's local-network boundary section says what an
overlay changes about it: the encryption and the pinning are the same, but
everyone admitted to the overlay is on that boundary.

Versions: MINOR for node-info, manual-nodes, ui-broker and both proxies --
each gained behavior visible over IPC or HTTP, and a node reachable only by
name is newly routable. PATCH for scanner, errors, workload-manager and
cluster-manager, which only pick up the netpick change. Product and
installer MINOR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
Add the 'vllm' engine type, display name, links, capabilities and icon,
and offer it in onboarding on Linux only, never pre-selected.

A node reported by the OpenAI-compatible proxy is no longer necessarily
LM Studio, so the bridge stops inferring the engine from the proxy's
name: a source that fronts one engine still names it, while the OpenAI
proxy's nodes carry per-engine model attribution and that is what says
whether a node runs LM Studio, vLLM, or both. The proxy's ready port
and its manual-node bridge now apply to every engine it fronts, and
those bridge calls name their engine so two engines on one host cannot
collide.

vLLM serves one model per process, chosen before it starts, so engine
settings gains a "Model to serve" field behind a new hasServedModel
capability. It relays the new engine:set-model, and EngineStatus gains
the configured model so the field can render what is actually set.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
The synthesized directory record is shape-identical to a discovered one, so
the renderer needs no special case for it -- which is exactly the property
worth a regression gate, because it is invisible until it breaks.

Drives the bridge state with the record the broker synthesizes for a peer
added by address, and asserts the whole surface a node card is built from:
the card itself, trust and membership, per-engine models with their loaded
state, the node-info poll target, and the hardware that comes back from it.

The address is a MagicDNS name throughout, since that is the part that is
new: nothing on this path may require an IP literal, and an empty address
is what a node with nowhere to be dialed looks like.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
The broker's cluster-manager port is a compiled-in constant, so only one
broker on a machine can hold it, and a previous test's broker can still be
tearing its workers down when the next one starts. When that happened this
test's own cluster-manager silently failed to bind and the pairing
completion was posted to whichever process did hold the port, which read as
a failed pairing.

Wait for the port to be released before starting, and for this broker's own
listener to exist before inviting, instead of sleeping a second and hoping.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
README, overview, architecture and the per-service READMEs now name three
engines, and engine-lifecycle gains a vLLM section covering what makes it
different: Linux only, adoption of an instance you already run as the
normal path, one model per process chosen before start, a first start
that can take many minutes, and no model operations.

lmstudio-proxy's README is rewritten as the OpenAI-compatible proxy that
fronts both LM Studio and vLLM, keeping the binary name as the wire
contract it is.

Versions: lmstudio-proxy MAJOR (node/add-manual and node/remove-manual
now require an engine); engine-manager, ui-broker, manual-nodes and
job-scheduler MINOR (additive IPC); node-scanner, errors,
workload-manager and tui PATCH (recompiled for the shared discovery key,
plus the TUI's panel label). node-info, node-settings and cluster-manager
are unchanged. Product and installer MINOR for the new engine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
The engine manager writes manifest overrides for its own host, and there is
no remote served-model control on the wire, so a peer's row could never hold
a value. Gate the row (and the accordion's "Ports and model" title) on the
local node, and drop the now-unreachable read-only branch from the row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
The parity and backend docs, and the READMEs of the broker, scheduler, TUI,
and node-scanner, all enumerate engines or service keys by hand and nothing
in the contract checker notices a missing engine. Add vLLM to each: the
routing row, the vl discovery key, the three advertise loops, the per-engine
schedule:priority list, the OpenAI-compatible proxy's two engines, the
per-engine node/set-local-backend, and the fact that vLLM has no model
operations at all because the model is a start-time setting.

Also fix a mangled sentence in the manual-nodes README ("The engine engine
ports are compiled in") introduced when the probe list grew a fourth leg.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
Combine vLLM engine support with Tailscale-reachable manual nodes.
Conflicts resolved in nvpair-manual-nodes (vLLM becomes a third engine
leg of the bare-host probe, keyed by the per-service port overrides, and
is never probed on a PAIR peer) and in versions.json (higher of each
component's bump kept).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
SGLang serves the same OpenAI HTTP surface as vLLM and, like vLLM, one
model per process named on the command line, so its manifest is the vLLM
one with SGLang's own facts: the `sglang serve --model-path {model}`
launch, stock port 30000, /health readiness with the same 30-minute
first-start budget, an install into a uv venv of `sglang[all]`, and
Linux only. Adoption of an instance the user already runs (a
`sglang serve` or the lmsysorg/sglang container) is the primary path,
exactly as for vLLM.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
(cherry picked from commit 5cd6185)
Four files have carried misaligned const, struct and field blocks since a
comment or a field was inserted into an already-aligned group without
re-running gofmt. Every one of them sits on or beside the SGLang change,
so fixing them first keeps that diff about SGLang rather than about
whitespace a formatter would have moved anyway.

Whitespace only: the compiled output is byte-identical, so no component
version moves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
(cherry picked from commit 24d6077)
A tensor-parallel vLLM group (--nnodes N) has one API on its head node and
headless workers that open no port. Document that PAIR adopts it on the head
only, that a worker correctly shows no engine, and that a worker must never be
restarted alone. PAIR does not combine GPUs or split a model across nodes; it
sees the group vLLM formed as one engine on that group's head node.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
(cherry picked from commit 3697089)
SGLang speaks the same OpenAI HTTP surface as LM Studio and vLLM, so it
joins them behind the one router rather than getting a binary of its own:
the proxy's engine table gains a third entry, it subscribes to sg as well
as lm and vl, and everything keyed by engine — routing candidates, model
attribution, workload tags, local backends, the manual overlay — carries
sglang without any new mechanism.

A peer advertises sg at the same value as lm and vl, its one proxy port,
so a three-engine peer still projects to a single routing target. The
resolution order is lmstudio, vllm, sglang, which is the deterministic
tie-break when more than one engine on a node claims the same model id.

Broker: runAutoAdvertiseSGLang registers sg at the OpenAI proxy's port
and hands the engine's loopback port to that proxy, keyed by engine so it
never disturbs the other two. Like vLLM there is no managed facade, so
the loop carries none of LM Studio's facade handling. /health is the
liveness probe and is a stronger signal here than elsewhere: SGLang binds
its port only once the model is loaded, so a listening /health means
ready to serve.

Manual nodes gain an sglang_* triple probed on 30000, disambiguated by
GET /get_model_info — a route neither LM Studio nor vLLM serves. probeVLLM
now refuses a server that answers it: /get_model_info is SGLang's own
route, so a server answering it is SGLang whatever else it serves, and
vLLM must not claim it. That is defense in depth rather than an observed
collision — SGLang answers no /version, so it never reaches the check —
and an error on /get_model_info reads as "not SGLang, carry on", so a slow
vLLM cannot flap to down.

Model ids get no interpretation anywhere. SGLang's /v1/models id is its
--served-model-name, which defaults to --model-path verbatim and is
routinely a bare local directory rather than a Hugging Face id.

The scheduler ranks sglang alongside the others, and the TUI's second
proxy panel names all three engines it fronts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
(cherry picked from commit 63aeb0c)
Every services README that enumerates engines or discovery keys by hand
now names SGLang: the proxy's three fronted engines and its lm/vl/sg
subscription, the sg key on the node-scanner's record, the broker's fourth
advertise loop and per-engine manual bridge, the scheduler's fourth
priority contract, the manual-node sglang_* triple and its probe leg, the
engine-manager's second one-model-per-process engine, and the TUI panel
that fronts all three.

Two things the vLLM pass left behind are corrected while the same
paragraphs are open: the proxy README still said a forwarded workload's
engine "is always lmstudio", which stopped being true when vLLM landed;
and the model-id shapes are now stated outright, because SGLang's
--served-model-name defaults to --model-path verbatim and so is routinely
a bare local directory rather than a Hugging Face id. Nothing in the tree
parses a model id, and the READMEs now say so.

The manual-nodes README describes the disambiguation from both sides:
/version identifies vLLM, /get_model_info identifies SGLang, and the vLLM
probe refuses a server that answers the latter because that route is
SGLang's own.

Versions: lmstudio-proxy MINOR (node/add-manual accepts a third engine and
the subscription gains sg — additive, the engine field was already
required); ui-broker, manual-nodes (sglang_* status fields and an sglang
port override), job-scheduler and engine-manager MINOR — the last for the
SGLang manifest bundled in the preceding commit, which shipped without a
bump of its own. node-scanner, errors and workload-manager PATCH
(recompiled against the shared discovery key) and tui PATCH (the panel
label). ollama-proxy, node-info, node-settings and cluster-manager carry
no source change and keep their versions, matching how the vLLM pass
treated them; the first three of those do link noderec and so are
technically recompiled, but a new unreferenced service key changes nothing
a reader of --version would learn. Product and installer MINOR for the new
engine.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
(cherry picked from commit bc28905)
Add the 'sglang' engine type, display name, links, capabilities and
icon, and offer it in onboarding on Linux only, never pre-selected.
The engine-manager id and our EngineType are the same string, so every
name translation is a pass-through; LM Studio stays the only engine
whose two spellings differ.

SGLang is another OpenAI-compatible server behind the same proxy as LM
Studio and vLLM, so it joins PROXY_SOURCE_ENGINES['lmstudio-proxy'] and
takes its presence from the per-engine model attribution that proxy
already stamps on every node it reports. PROXY_REPRESENTATIVE_ENGINES
is deliberately unchanged: it holds one entry per proxy *process*, not
per engine.

Like vLLM it serves one model per process, chosen before it starts and
resident until it stops, so it declares hasServedModel and no model
operations at all. Its /v1/models id is the --served-model-name, which
defaults to --model-path verbatim: either a Hugging Face repo id or a
local model directory. formatModelDisplayName grew a rule for that
second shape, because the Hugging Face formatter renders
/models/my-model as "models/my model"; a path now shows its
last segment unhumanised, since a directory name is only recognisable
as the operator typed it. The rule sits in the shared default branch,
so vLLM — which takes local paths too — gets it as well, while the
engines with formatting rules of their own are untouched. It defers to
the 256-char input cap: that cap slices from the right, exactly where a
path keeps its last segment, so a capped path is handed back truncated
rather than named after a truncated middle segment.

ManualServicePorts gains sglang (stock port 30000) alongside vllm, and
the stale "carried but not probed yet" note on that field goes with it.

SGLang's logo is not redistributable, so its tile is drawn the way
vLLM's is: "SG" in white on #c2410c. Orange rather than another blue —
the two tiles sit next to each other in the backend list, and
blue-against-violet is the pair the common colour-vision deficiencies
collapse. White on #c2410c is 5.2:1, past the 4.5:1 floor.

desktop/docs/services-api.md needed no change: it is generated by
verify-service-contracts.ts and enumerates JSON-RPC methods, not
engines.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
(cherry picked from commit 99c421f)
SGLang joins Ollama, LM Studio and vLLM as a fourth engine, and every
place the docs enumerate engines or list engine ports does it by hand,
so nothing catches an engine that was added to the code but not the
prose. Add it to each: the README's engines row and its two later
mentions; the overview's engine definition and proxy sentence;
architecture's diagram label, engine definition, model-id matching,
one-pool note, install-location sentence and port table; and the
engine-lifecycle engine lists, adoption sentence and
adopted-port-change bullets.

engine-lifecycle gains a full SGLang section next to vLLM's, covering
what is specific to it: stock port 30000 behind the same
OpenAI-compatible proxy, a port that opens only once the model has
finished loading so a loading node shows no engine at all, GET /health
for readiness and liveness, GET /get_model_info as what tells a bare
SGLang host apart from any other OpenAI server, a model directory as well
as a Hugging Face id under Model to serve, and --served-model-name
defaulting to --model-path verbatim, which is the id a user actually sees
in Endpoints. A multi-node subsection mirrors vLLM's: --nnodes with
--node-rank and --dist-init-addr, adopted on the head only, worker ranks
correctly showing no engine, and never restarting one alone. Both
multi-node sections say plainly that the sharding is the engine's own and
that PAIR neither combines GPUs nor splits a model across nodes; it sees
the group as one engine on its head node.

getting-started, terminal-interface and troubleshooting enumerate engines
too, but the vLLM commits deliberately left them alone; naming SGLang in
a page that has never heard of vLLM would read worse than leaving it, so
they are untouched here as well.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
(cherry picked from commit 84c6b98)
On current SGLang builds GET /health is not a liveness check: it runs a
real forward pass and answers in almost exactly one second (measured
1.001 s, the same as /health_generate), where /get_model_info and
/v1/models answer in under a millisecond. PAIR probes an engine's ready
route constantly — every engine:status from the broker's advertise loop,
every model-list request from every peer's scanner, the loaded-model
watcher, and the health loop — and each of those holds the engine's
operation mutex for the probe. On a node with several peers that adds up
to more than a second of mutex demand per second, so the model endpoint
serialized and then never drained: peers' requests piled up in
CLOSE-WAIT, the engine-manager's descriptor count climbed past 240, and
the node's own proxy answered 502 for the model it was serving, while a
peer (which had fetched the list once before the queue formed) routed
to it fine.

Point every SGLang probe at /get_model_info instead: the manifest's ready
and health probes, and the broker's checkSGLangHealth. It is instant, it
is SGLang's own route — so, unlike /health, which vLLM also serves, a 200
positively identifies the engine — and because SGLang binds its port
only once the model is loaded, a 200 still means ready to serve. The
user doc says why /health is avoided so nobody "fixes" it back.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Signed-off-by: Can GULDOGAN <cguldogan@gmail.com>
(cherry picked from commit b1ef53b)
@Noah-Tervalon-Nvidia

Copy link
Copy Markdown
Collaborator

Hey, thanks for adding this! We don't have a direction right now on adding SGLang, but will keep you posted.

@jlacroix82

Copy link
Copy Markdown

One hazard to fold in from running this on a 4-node SGLang head for a week: on SGLang, GET /health is not a cheap liveness ping. It runs a short real generation (a 256-token prefill plus a few decode steps on the build I have, lmsysorg/sglang:dev-dsv41 serving DeepSeek-V4.1-Flash) and takes ~1.0 s. This PR's manifest uses /health for ready and health, and the advertiser reuses checkVLLMHealth, so PAIR ends up issuing ~3,500 generations an hour against an idle head. Two effects:

  • engine:status holds the engine lock for the ~1 s probe, and the broker/loaded-watch/desktop polls add up to about one status call per second, so /v1/models on that engine-manager never completes (details and code pointers in the issue I just filed).
  • On that build the head leaked ~0.4 GB/h of front-end memory under that probe load until earlyoom killed the scheduler 20 h after boot; the three worker ranks stayed flat.

/get_model_info (the endpoint this PR already uses for identity) is ~1 ms, runs nothing, and its model_path field is a positive identity check the OpenAI proxy's facade can never satisfy. I switched all three probe sites to it in my fork, with a test that fails if the advertiser ever hits /health: jlacroix82@c5b9be7. Happy to send it against this PR's branch if you would rather take it here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants