Skip to content
Merged
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
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ SPDX-License-Identifier: Apache-2.0

NVIDIA Personal AI Router (PAIR) is a local inference router for a group of
compatible computers on the same network. It discovers participating nodes,
manages supported inference engines, and presents Ollama-compatible and
OpenAI-compatible proxy endpoints to applications and agents. Independent
requests can be routed to eligible nodes according to engine availability,
model availability, and current workload.
manages supported inference engines, and presents local proxy endpoints for
Ollama-compatible, OpenAI-compatible, and Anthropic Messages API requests.
Independent 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
Expand Down
4 changes: 2 additions & 2 deletions docs/getting-started.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -406,8 +406,8 @@ What that base URL serves depends on the engine behind it:

| Endpoint | Default base URL | Paths it serves |
| --- | --- | --- |
| Ollama | `http://127.0.0.1:11434` | Ollama's own API — `/api/chat`, `/api/generate`, `/api/embed`, `/api/tags` — and the OpenAI-compatible `/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`, `/v1/models` |
| LM Studio | `http://127.0.0.1:1234` | The OpenAI-compatible paths only |
| Ollama | `http://127.0.0.1:11434` | Ollama's own API — `/api/chat`, `/api/generate`, `/api/embed`, `/api/tags` — and the OpenAI-compatible `/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`, `/v1/models`, plus the Anthropic Messages API `/v1/messages` |
| LM Studio | `http://127.0.0.1:1234` | The OpenAI-compatible paths (`/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`, `/v1/models`) and the Anthropic Messages API `/v1/messages` |

The distinction matters when you fill in an application's settings. A client
written against the OpenAI API usually wants the `/v1` included, as in
Expand Down
13 changes: 7 additions & 6 deletions docs/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,14 +76,14 @@ you form a cluster, only the last step repeats.

## Request Model

An application sends an ordinary Ollama-compatible or OpenAI-compatible HTTP
request to a local proxy. The proxy selects one eligible node and forwards the
whole request. That node's engine performs the inference, and the response
streams back through the proxy.
An application sends an ordinary Ollama-compatible or OpenAI-compatible
request, or an Anthropic Messages API request, to a local proxy. The proxy
selects one eligible node and forwards the whole request. That node's engine
performs the inference, and the response streams back through the proxy.

```mermaid
flowchart LR
Client["Your AI app or agent"] -->|"Ollama- or OpenAI-compatible request"| Endpoint["PAIR endpoint<br/>on your machine"]
Client["Your AI app or agent"] -->|"Ollama, OpenAI, or Anthropic Messages request"| Endpoint["PAIR endpoint<br/>on your machine"]
Endpoint <-->|"encrypted both ways"| Node["A paired node<br/>with the model"]
Node --> Engine["Inference engine"]
```
Expand Down Expand Up @@ -136,7 +136,8 @@ PAIR provides these capabilities:

- A local endpoint for compatible AI applications and development tools.
- LAN discovery plus manually configured nodes.
- Ollama-compatible and LM Studio/OpenAI-compatible routing proxies.
- Routing proxies for Ollama-compatible, OpenAI-compatible, and Anthropic
Messages API requests.
- Pairing and cluster membership managed by the background services.
- Model-aware, workload-informed routing of independent requests.
- Encrypted routing between machines: a request sent to another node travels over
Expand Down
2 changes: 1 addition & 1 deletion services/nvpair-proxy/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ and the health crash key are matched against each other, so they move together
| Standalone port, used when `port` is omitted | 11435 | 1234 |
| Persisted-port file (declared, not derived) | `proxy-port.json` | `lmstudio-proxy-port.json` |
| Model-list routes | `GET /api/tags` (native), `GET /v1/models` (OpenAI) | `GET /v1/models` (OpenAI) |
| Inference routes | `/api/generate`, `/api/chat`, `/api/embeddings`, `/api/embed`, plus the OpenAI set | `/v1/chat/completions`, `/v1/completions`, `/v1/embeddings` |
| Inference routes | `/api/generate`, `/api/chat`, `/api/embeddings`, `/api/embed`, plus the OpenAI and Anthropic Messages sets | `/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`, `/v1/messages` |
| Model naming | untagged means `:latest`, so `llama3` and `llama3:latest` are one model | identifiers compared byte for byte |

The route table is a **classifier, not an allowlist**. An unlisted path is
Expand Down
51 changes: 33 additions & 18 deletions services/nvpair-proxy/engines.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ package main
// nvpair-shared/engines.

import (
"slices"
"strings"

"nvpair-shared/engines"
Expand All @@ -30,8 +31,8 @@ import (
// POST to /v1/models is not a model list, and a GET to /api/chat is not
// inference. Folding them into one constant keeps the two from disagreeing.
//
// The dialect distinction is meaningful only for model-list roles. All seven
// of Ollama's inference paths are handled identically — no envelope, identity
// The dialect distinction is meaningful only for model-list roles. All of
// Ollama's inference paths are handled identically — no envelope, identity
// field or response shape is selected — so there is deliberately no
// per-dialect inference role.
type routeRole int
Expand Down Expand Up @@ -116,43 +117,57 @@ type engineProfile struct {
SupportsHostAlias bool
}

// openAIInferenceRoutes is the inference surface every OpenAI-compatible
// engine exposes. Ollama serves these alongside its native routes.
// ollamaBaseRoutes is the engine-specific surface that Ollama exposes before
// the shared compatibility routes are added.
var ollamaBaseRoutes = []route{
{Path: "/api/generate", Role: roleInferencePOST},
{Path: "/api/chat", Role: roleInferencePOST},
{Path: "/api/embeddings", Role: roleInferencePOST},
{Path: "/api/embed", Role: roleInferencePOST},
{Path: "/api/tags", Role: roleModelListNativeGET},
{Path: "/v1/models", Role: roleModelListOpenAIGET},
}

// lmStudioBaseRoutes is the engine-specific surface that LM Studio exposes
// before the shared compatibility routes are added.
var lmStudioBaseRoutes = []route{
{Path: "/v1/models", Role: roleModelListOpenAIGET},
}

// openAIInferenceRoutes is the OpenAI-compatible inference surface.
var openAIInferenceRoutes = []route{
{Path: "/v1/chat/completions", Role: roleInferencePOST},
{Path: "/v1/completions", Role: roleInferencePOST},
{Path: "/v1/embeddings", Role: roleInferencePOST},
}

// anthropicInferenceRoutes is the Anthropic-compatible inference surface.
var anthropicInferenceRoutes = []route{
{Path: "/v1/messages", Role: roleInferencePOST},
}

var profiles = buildProfiles()

func buildProfiles() []engineProfile {
ollama, _ := engines.ByName("ollama")
lmstudio, _ := engines.ByName("lmstudio")
ollamaRoutes := slices.Concat(ollamaBaseRoutes, openAIInferenceRoutes, anthropicInferenceRoutes)
lmStudioRoutes := slices.Concat(lmStudioBaseRoutes, openAIInferenceRoutes, anthropicInferenceRoutes)

return []engineProfile{
{
Engine: ollama,
StandalonePort: 11435,
Routes: append([]route{
{Path: "/api/generate", Role: roleInferencePOST},
{Path: "/api/chat", Role: roleInferencePOST},
{Path: "/api/embeddings", Role: roleInferencePOST},
{Path: "/api/embed", Role: roleInferencePOST},
{Path: "/api/tags", Role: roleModelListNativeGET},
{Path: "/v1/models", Role: roleModelListOpenAIGET},
}, openAIInferenceRoutes...),
Engine: ollama,
StandalonePort: 11435,
Routes: ollamaRoutes,
ModelNaming: impliedLatestTag,
ReservedPersistedPort: 0,
SupportsHostAlias: true,
},
{
Engine: lmstudio,
StandalonePort: 1234,
Routes: append([]route{
{Path: "/v1/models", Role: roleModelListOpenAIGET},
}, openAIInferenceRoutes...),
ModelNaming: exactID,
Routes: lmStudioRoutes,
ModelNaming: exactID,
// 1235 is where engine-manager runs a managed LM Studio, so a
// proxy that restored it would sit on the engine's own port. The
// stored value predates the current default of 1234.
Expand Down
2 changes: 2 additions & 0 deletions services/nvpair-proxy/engines_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,12 +29,14 @@ func TestRoleForClassifiesOnlyDeclaredRoutes(t *testing.T) {
}{
{"ollama native chat", ollama, "POST", "/api/chat", roleInferencePOST, true},
{"ollama openai chat", ollama, "POST", "/v1/chat/completions", roleInferencePOST, true},
{"ollama anthropic messages", ollama, "POST", "/v1/messages", roleInferencePOST, true},
{"ollama native list", ollama, "GET", "/api/tags", roleModelListNativeGET, true},
{"ollama openai list", ollama, "GET", "/v1/models", roleModelListOpenAIGET, true},
{"ollama passthrough", ollama, "POST", "/api/pull", 0, false},
{"ollama version passthrough", ollama, "GET", "/api/version", 0, false},

{"lmstudio chat", lmstudio, "POST", "/v1/chat/completions", roleInferencePOST, true},
{"lmstudio anthropic messages", lmstudio, "POST", "/v1/messages", roleInferencePOST, true},
{"lmstudio list", lmstudio, "GET", "/v1/models", roleModelListOpenAIGET, true},
// LM Studio serves no native Ollama routes, so /api/chat is not
// inference for it — it is forwarded verbatim like any other path.
Expand Down
81 changes: 81 additions & 0 deletions services/nvpair-proxy/failover_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,87 @@ func TestHandleHTTP_StrictModelRouting(t *testing.T) {
})
}

// TestHandleHTTP_InferenceRouting proves each engine inference route is
// model-routed, retries a model-not-found response, and
// forwards the request path and body unchanged.
func TestHandleHTTP_InferenceRouting(t *testing.T) {
test := func(name, path string, profiles ...engineProfile) {
t.Run(name, func(t *testing.T) {
for _, profile := range profiles {
t.Run(profile.Name, func(t *testing.T) {
requestedModel := "requested-model"
advertisedModel := profile.normalizeModel(requestedModel)

wrongModel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
t.Error("wrong-model node should not receive request")
w.WriteHeader(http.StatusOK)
}))
defer wrongModel.Close()

missing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNotFound)
if _, err := io.WriteString(w, `{"error":"model not found"}`); err != nil {
t.Errorf("write missing-model response: %v", err)
}
}))
defer missing.Close()

var gotBody string
var gotPath string
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read forwarded request body: %v", err)
w.WriteHeader(http.StatusInternalServerError)
return
}
gotBody = string(body)
gotPath = r.URL.Path
w.WriteHeader(http.StatusOK)
}))
defer good.Close()

disc := NewDiscovery()
disc.AddManual(nodeForModel(t, "wrong", wrongModel.URL, "different-model"))
disc.AddManual(nodeForModel(t, "missing", missing.URL, advertisedModel))
disc.AddManual(nodeForModel(t, "good", good.URL, advertisedModel))
p := testProxy(profile, disc, profile.FacadePort)
p.soleFacade().SetSelected("wrong")
// Stable node ordering would send this request to "good" first and
// never exercise 404 failover. Prioritize "missing" so the test
// independently proves both model filtering and retry behavior.
p.SetPriority([]string{"missing", "good"})

body := `{"model":"requested-model","max_tokens":1024,"messages":[{"role":"user","content":"Hello"}]}`
rec := httptest.NewRecorder()
p.soleFacade().handleHTTP(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)))

if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 after 404 failover", rec.Code)
}
if gotBody != body {
t.Errorf("node got body %q, want %q", gotBody, body)
}
if gotPath != path {
t.Errorf("path = %q, want %q", gotPath, path)
}
})
}
})
}

ollama := ollamaCase(t).profile
lmstudio := lmstudioCase(t).profile
test("native Ollama generate", "/api/generate", ollama)
test("native Ollama chat", "/api/chat", ollama)
test("native Ollama embeddings", "/api/embeddings", ollama)
test("native Ollama embed", "/api/embed", ollama)
test("OpenAI chat completions", "/v1/chat/completions", ollama, lmstudio)
test("OpenAI completions", "/v1/completions", ollama, lmstudio)
test("OpenAI embeddings", "/v1/embeddings", ollama, lmstudio)
test("Anthropic messages", "/v1/messages", ollama, lmstudio)
}

func TestHandleHTTP_NoAdvertisedModelRejectsLocally(t *testing.T) {
forEachEngine(t, func(t *testing.T, tc engineCase) {
hits := 0
Expand Down
6 changes: 5 additions & 1 deletion services/tests/model_routing_interop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ func TestStrictModelRoutingAcrossProcesses(t *testing.T) {
}
cases := []proxyCase{
{name: "ollama", rpcPrefix: "ollama-proxy", path: "/api/chat", port: ollamaPort},
{name: "ollama-anthropic", rpcPrefix: "ollama-proxy", path: "/v1/messages", port: ollamaPort},
{name: "lmstudio", rpcPrefix: "lmstudio-proxy", path: "/v1/chat/completions", port: lmstudioPort},
{name: "lmstudio-anthropic", rpcPrefix: "lmstudio-proxy", path: "/v1/messages", port: lmstudioPort},
}
client := &http.Client{Timeout: 5 * time.Second}
t.Cleanup(client.CloseIdleConnections)
Expand Down Expand Up @@ -111,7 +113,9 @@ func TestStrictModelRoutingAcrossProcesses(t *testing.T) {
requestID++
}
callBrokerRPC(t, stdin, msgs, requestID, tc.rpcPrefix+":node/set-priority", map[string]any{
"generation": 1,
// Both facades share one proxy process, so every snapshot must
// advance the process-wide generation.
"generation": caseIndex + 1,
"nodes": []string{missingID, unknownID, owner404ID, ownerOKID},
})

Expand Down
Loading