From 2eadb00eb7848217b9745158b9d16a3feb4c81f6 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Fri, 4 Sep 2026 19:35:03 +0100 Subject: [PATCH 1/2] Probe manual nodes for llmman on 17434 as an Ollama-API engine llmman (https://github.com/llmmanorg/llmman) serves the Ollama API on 17434, but manual nodes were only probed for Ollama on 11434, so a node running llmman showed no inference engine. Retry the probe on 17434 and report the answering port in ollama_port; the broker and ollama-proxy already honour that field, so nothing else changes. Ollama wins when both answer. Signed-off-by: Eric Curtin --- services/nvpair-manual-nodes/README.md | 6 ++- services/nvpair-manual-nodes/manager.go | 24 +++++++++-- services/nvpair-manual-nodes/manager_test.go | 44 ++++++++++++++++---- 3 files changed, 62 insertions(+), 12 deletions(-) diff --git a/services/nvpair-manual-nodes/README.md b/services/nvpair-manual-nodes/README.md index 52c5de7b1..6b90627aa 100644 --- a/services/nvpair-manual-nodes/README.md +++ b/services/nvpair-manual-nodes/README.md @@ -62,6 +62,8 @@ Emitted when a manually added node has been probed and its initial status determ Each node is probed for both inference engines: Ollama on its default `:11434` (`GET /` + `/api/tags`) and LM Studio on its default `:1234` (`GET /v1/models`, which doubles as the liveness check and the model list). `lmstudio_up` / `lmstudio_port` / `lmstudio_models` mirror the `ollama_*` fields and let a supervising broker bridge the node into that engine's `nvpair-proxy` instance the same way it bridges Ollama into its own. A node can run either engine, both, or neither. +When nothing answers on `:11434`, the same Ollama probe is retried on `:17434`, the default port of [llmman](https://github.com/llmmanorg/llmman), which serves the Ollama API. Such a node reports `ollama_up: true` with `ollama_port: 17434` and is bridged into `ollama-proxy` like an Ollama node. + ### `node/updated` Emitted when a periodic probe detects a change (service going up/down, models, @@ -134,13 +136,13 @@ Changes the log level at runtime. Accepted as either a request (answered with `{ Each manual node is probed every 10 seconds, with a 3-second timeout per leg, for: -- **Ollama** on port 11434: health check (`GET /`) and model list (`GET /api/tags`) +- **Ollama** on port 11434: health check (`GET /`) and model list (`GET /api/tags`). If it does not answer, the same probe is sent to port 17434, where [llmman](https://github.com/llmmanorg/llmman) serves the Ollama API; `ollama_port` reports whichever port answered - **LM Studio** on port 1234: `GET /v1/models`, which doubles as the liveness check and the model list - **Node Info** on port 14318, or `tls_port` over HTTPS: hardware inventory and identity (`GET /v1/node-info`) A node can have any combination of these, or none if the target is unreachable. Status changes trigger `node/updated` events. Because change detection compares CPU, memory, and GPU values, a node running node-info emits a `node/updated` on most probe cycles as utilization moves. -The three engine ports are compiled in: only the node-info leg's port can be moved, via `tls_port`. A remote engine on a non-default port is not discovered. +The engine ports are compiled in: only the node-info leg's port can be moved, via `tls_port`. A remote engine on a non-default port is not discovered. ## Shutdown diff --git a/services/nvpair-manual-nodes/manager.go b/services/nvpair-manual-nodes/manager.go index 55a4040aa..4b238d829 100644 --- a/services/nvpair-manual-nodes/manager.go +++ b/services/nvpair-manual-nodes/manager.go @@ -251,7 +251,7 @@ func (m *Manager) probeNode(entry ManualEntry) { addr := entry.Address id := nodeID(entry) - ollamaUp, ollamaModels := m.probeOllama(addr, 11434) + ollamaUp, ollamaAPIPort, ollamaModels := m.probeOllamaAPI(addr) lmStudioUp, lmStudioModels := m.probeLMStudio(addr, lmStudioPort) // Pick scheme + port + client based on the entry's TLS hint. @@ -285,7 +285,7 @@ func (m *Manager) probeNode(entry ManualEntry) { Name: entry.Name, Address: addr, OllamaUp: ollamaUp, - OllamaPort: 11434, + OllamaPort: ollamaAPIPort, OllamaModels: ollamaModels, LMStudioUp: lmStudioUp, LMStudioPort: lmStudioPort, @@ -403,6 +403,24 @@ func probeFailedID(nodeID string) string { // manager (which only governs the local engine). const lmStudioPort = 1234 +// Default ports of Ollama and of llmman (https://github.com/llmmanorg/llmman), +// which serves the same Ollama API on 17434. +const ( + ollamaPort = 11434 + llmmanPort = 17434 +) + +// probeOllamaAPI probes Ollama first, then llmman, and returns whether one +// answered, its port (ollamaPort when neither did) and its models. +func (m *Manager) probeOllamaAPI(addr string) (bool, int, []string) { + for _, port := range []int{ollamaPort, llmmanPort} { + if up, models := m.probeOllama(addr, port); up { + return true, port, models + } + } + return false, ollamaPort, nil +} + // probeLMStudio checks LM Studio's OpenAI-compatible server on addr:port. A // single GET /v1/models doubles as the liveness check and the model list (the // response is {"data":[{"id":"..."}],...}). Returns whether it is up and the @@ -539,7 +557,7 @@ func (m *Manager) addNode(entry ManualEntry) ManualNodeStatus { ID: id, Name: entry.Name, Address: entry.Address, - OllamaPort: 11434, + OllamaPort: ollamaPort, NodeInfoPort: nodeInfoPort, TLSEnabled: entry.TLSPort > 0, MTLSRequired: entry.TLSPort > 0 && entry.MTLS, diff --git a/services/nvpair-manual-nodes/manager_test.go b/services/nvpair-manual-nodes/manager_test.go index e52d9d5df..09b738011 100644 --- a/services/nvpair-manual-nodes/manager_test.go +++ b/services/nvpair-manual-nodes/manager_test.go @@ -11,6 +11,7 @@ import ( "io" "net" "net/http" + "strconv" "strings" "sync" "testing" @@ -84,7 +85,18 @@ func newTestManager() (*Manager, *captureRW, *fakeRoundTripper) { } func configureHealthyNode(rt *fakeRoundTripper, addr string, models []string, info NodeInfoResponse) { - host := net.JoinHostPort(addr, "11434") + configureHealthyOllamaAPI(rt, addr, ollamaPort, models) + + infoHost := net.JoinHostPort(addr, "14318") + rt.set(http.MethodGet, infoHost, "/v1/node-info", func(*http.Request) (*http.Response, error) { + data, _ := json.Marshal(info) + return httpJSON(http.StatusOK, string(data)) + }) +} + +// configureHealthyOllamaAPI registers GET / and GET /api/tags on addr:port. +func configureHealthyOllamaAPI(rt *fakeRoundTripper, addr string, port int, models []string) { + host := net.JoinHostPort(addr, strconv.Itoa(port)) rt.set(http.MethodGet, host, "/", func(*http.Request) (*http.Response, error) { return httpJSON(http.StatusOK, `{}`) }) @@ -102,12 +114,6 @@ func configureHealthyNode(rt *fakeRoundTripper, addr string, models []string, in data, _ := json.Marshal(payload) return httpJSON(http.StatusOK, string(data)) }) - - infoHost := net.JoinHostPort(addr, "14318") - rt.set(http.MethodGet, infoHost, "/v1/node-info", func(*http.Request) (*http.Response, error) { - data, _ := json.Marshal(info) - return httpJSON(http.StatusOK, string(data)) - }) } // configureHealthyLMStudio registers a 200 GET /v1/models on addr:1234 @@ -154,6 +160,30 @@ func TestProbeLMStudioReportsModels(t *testing.T) { } } +// TestProbeOllamaAPIFallsBackToLLMMan: Ollama wins when both answer, llmman is +// reported with its port when only it answers, neither reports down on 11434. +func TestProbeOllamaAPIFallsBackToLLMMan(t *testing.T) { + m, _, rt := newTestManager() + configureHealthyOllamaAPI(rt, "both.local", ollamaPort, []string{"llama3"}) + configureHealthyOllamaAPI(rt, "both.local", llmmanPort, []string{"gemma4"}) + configureHealthyOllamaAPI(rt, "llmman.local", llmmanPort, []string{"gemma4", "qwen3.8"}) + + up, port, models := m.probeOllamaAPI("both.local") + if !up || port != ollamaPort || len(models) != 1 || models[0] != "llama3" { + t.Fatalf("both: up=%v port=%d models=%#v, want ollama on %d", up, port, models, ollamaPort) + } + + up, port, models = m.probeOllamaAPI("llmman.local") + if !up || port != llmmanPort || len(models) != 2 || models[0] != "gemma4" || models[1] != "qwen3.8" { + t.Fatalf("llmman: up=%v port=%d models=%#v, want llmman on %d", up, port, models, llmmanPort) + } + + up, port, models = m.probeOllamaAPI("absent.local") + if up || port != ollamaPort || models != nil { + t.Fatalf("absent: up=%v port=%d models=%#v, want down on %d", up, port, models, ollamaPort) + } +} + func requestMessage(id int, method string, params any) *Message { idData, _ := json.Marshal(id) idRaw := json.RawMessage(idData) From af74c6d7660890790329d3de7558c58a8414a734 Mon Sep 17 00:00:00 2001 From: Eric Curtin Date: Fri, 23 Oct 2026 02:31:08 +0100 Subject: [PATCH 2/2] Use a table and testify in the llmman probe test Signed-off-by: Eric Curtin --- services/nvpair-manual-nodes/manager_test.go | 33 +++++++++++--------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/services/nvpair-manual-nodes/manager_test.go b/services/nvpair-manual-nodes/manager_test.go index 0a7f9d119..cc3c70842 100644 --- a/services/nvpair-manual-nodes/manager_test.go +++ b/services/nvpair-manual-nodes/manager_test.go @@ -158,27 +158,32 @@ func TestProbeLMStudio_ReportsModels(t *testing.T) { assert.Nil(t, downModels, "expected absent lmstudio down, got up (%v, %v)", downUp, downModels) } -// TestProbeOllamaAPIFallsBackToLLMMan: Ollama wins when both answer, llmman is +// TestProbeOllamaAPI_FallsBackToLLMMan: Ollama wins when both answer, llmman is // reported with its port when only it answers, neither reports down on 11434. -func TestProbeOllamaAPIFallsBackToLLMMan(t *testing.T) { +func TestProbeOllamaAPI_FallsBackToLLMMan(t *testing.T) { m, _, rt := newTestManager() configureHealthyOllamaAPI(rt, "both.local", ollamaPort, []string{"llama3"}) configureHealthyOllamaAPI(rt, "both.local", llmmanPort, []string{"gemma4"}) configureHealthyOllamaAPI(rt, "llmman.local", llmmanPort, []string{"gemma4", "qwen3.8"}) - up, port, models := m.probeOllamaAPI("both.local") - if !up || port != ollamaPort || len(models) != 1 || models[0] != "llama3" { - t.Fatalf("both: up=%v port=%d models=%#v, want ollama on %d", up, port, models, ollamaPort) - } - - up, port, models = m.probeOllamaAPI("llmman.local") - if !up || port != llmmanPort || len(models) != 2 || models[0] != "gemma4" || models[1] != "qwen3.8" { - t.Fatalf("llmman: up=%v port=%d models=%#v, want llmman on %d", up, port, models, llmmanPort) + tests := []struct { + name string + addr string + wantUp bool + wantPort int + wantModels []string + }{ + {"both answer", "both.local", true, ollamaPort, []string{"llama3"}}, + {"only llmman", "llmman.local", true, llmmanPort, []string{"gemma4", "qwen3.8"}}, + {"neither", "absent.local", false, ollamaPort, nil}, } - - up, port, models = m.probeOllamaAPI("absent.local") - if up || port != ollamaPort || models != nil { - t.Fatalf("absent: up=%v port=%d models=%#v, want down on %d", up, port, models, ollamaPort) + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + up, port, models := m.probeOllamaAPI(tc.addr) + assert.Equal(t, tc.wantUp, up, "up") + assert.Equal(t, tc.wantPort, port, "port") + assert.Equal(t, tc.wantModels, models, "models") + }) } }