Skip to content

Commit 97b21ed

Browse files
committed
rebase onto develop
Signed-off-by: Kaylee Lubick <klubick@nvidia.com>
1 parent 7749b54 commit 97b21ed

6 files changed

Lines changed: 129 additions & 97 deletions

File tree

‎README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@ SPDX-License-Identifier: Apache-2.0
1010

1111
NVIDIA Personal AI Router (PAIR) is a local inference router for a group of
1212
compatible computers on the same network. It discovers participating nodes,
13-
manages supported inference engines, and presents Ollama-compatible and
14-
OpenAI-compatible proxy endpoints to applications and agents. Independent
15-
requests can be routed to eligible nodes according to engine availability,
16-
model availability, and current workload.
13+
manages supported inference engines, and presents local proxy endpoints for
14+
Ollama-compatible, OpenAI-compatible, and Anthropic Messages API requests.
15+
Independent requests can be routed to eligible nodes according to engine
16+
availability, model availability, and current workload.
1717

1818
PAIR is useful for concurrent local workloads such as multi-agent applications.
1919
Prompts and responses are intended to remain on the local network when every

‎docs/overview.mdx‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -76,14 +76,14 @@ you form a cluster, only the last step repeats.
7676

7777
## Request Model
7878

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

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

137137
- A local endpoint for compatible AI applications and development tools.
138138
- LAN discovery plus manually configured nodes.
139-
- Ollama-compatible and LM Studio/OpenAI-compatible routing proxies.
139+
- Routing proxies for Ollama-compatible, OpenAI-compatible, and Anthropic
140+
Messages API requests.
140141
- Pairing and cluster membership managed by the background services.
141142
- Model-aware, workload-informed routing of independent requests.
142143
- Encrypted routing between machines: a request sent to another node travels over

‎services/nvpair-proxy/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ and the health crash key are matched against each other, so they move together
102102
| Standalone port, used when `port` is omitted | 11435 | 1234 |
103103
| Persisted-port file (declared, not derived) | `proxy-port.json` | `lmstudio-proxy-port.json` |
104104
| Model-list routes | `GET /api/tags` (native), `GET /v1/models` (OpenAI) | `GET /v1/models` (OpenAI) |
105-
| Inference routes | `/api/generate`, `/api/chat`, `/api/embeddings`, `/api/embed`, plus the OpenAI set and `/v1/messages` | `/v1/chat/completions`, `/v1/completions`, `/v1/embeddings`, `/v1/messages` |
105+
| 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` |
106106
| Model naming | untagged means `:latest`, so `llama3` and `llama3:latest` are one model | identifiers compared byte for byte |
107107

108108
The route table is a **classifier, not an allowlist**. An unlisted path is

‎services/nvpair-proxy/engines.go‎

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ import (
3030
// POST to /v1/models is not a model list, and a GET to /api/chat is not
3131
// inference. Folding them into one constant keeps the two from disagreeing.
3232
//
33-
// The dialect distinction is meaningful only for model-list roles. All eight
34-
// of Ollama's inference paths are handled identically — no envelope, identity
33+
// The dialect distinction is meaningful only for model-list roles. All of
34+
// Ollama's inference paths are handled identically — no envelope, identity
3535
// field or response shape is selected — so there is deliberately no
3636
// per-dialect inference role.
3737
type routeRole int
@@ -116,12 +116,15 @@ type engineProfile struct {
116116
SupportsHostAlias bool
117117
}
118118

119-
// openAIInferenceRoutes is the inference surface every OpenAI-compatible
120-
// engine exposes. Ollama serves these alongside its native routes.
119+
// openAIInferenceRoutes is the OpenAI-compatible inference surface.
121120
var openAIInferenceRoutes = []route{
122121
{Path: "/v1/chat/completions", Role: roleInferencePOST},
123122
{Path: "/v1/completions", Role: roleInferencePOST},
124123
{Path: "/v1/embeddings", Role: roleInferencePOST},
124+
}
125+
126+
// anthropicInferenceRoutes is the Anthropic-compatible inference surface.
127+
var anthropicInferenceRoutes = []route{
125128
{Path: "/v1/messages", Role: roleInferencePOST},
126129
}
127130

@@ -130,30 +133,36 @@ var profiles = buildProfiles()
130133
func buildProfiles() []engineProfile {
131134
ollama, _ := engines.ByName("ollama")
132135
lmstudio, _ := engines.ByName("lmstudio")
136+
ollamaRoutes := []route{
137+
{Path: "/api/generate", Role: roleInferencePOST},
138+
{Path: "/api/chat", Role: roleInferencePOST},
139+
{Path: "/api/embeddings", Role: roleInferencePOST},
140+
{Path: "/api/embed", Role: roleInferencePOST},
141+
{Path: "/api/tags", Role: roleModelListNativeGET},
142+
{Path: "/v1/models", Role: roleModelListOpenAIGET},
143+
}
144+
ollamaRoutes = append(ollamaRoutes, openAIInferenceRoutes...)
145+
ollamaRoutes = append(ollamaRoutes, anthropicInferenceRoutes...)
146+
lmstudioRoutes := []route{
147+
{Path: "/v1/models", Role: roleModelListOpenAIGET},
148+
}
149+
lmstudioRoutes = append(lmstudioRoutes, openAIInferenceRoutes...)
150+
lmstudioRoutes = append(lmstudioRoutes, anthropicInferenceRoutes...)
133151

134152
return []engineProfile{
135153
{
136-
Engine: ollama,
137-
StandalonePort: 11435,
138-
Routes: append([]route{
139-
{Path: "/api/generate", Role: roleInferencePOST},
140-
{Path: "/api/chat", Role: roleInferencePOST},
141-
{Path: "/api/embeddings", Role: roleInferencePOST},
142-
{Path: "/api/embed", Role: roleInferencePOST},
143-
{Path: "/api/tags", Role: roleModelListNativeGET},
144-
{Path: "/v1/models", Role: roleModelListOpenAIGET},
145-
}, openAIInferenceRoutes...),
154+
Engine: ollama,
155+
StandalonePort: 11435,
156+
Routes: ollamaRoutes,
146157
ModelNaming: impliedLatestTag,
147158
ReservedPersistedPort: 0,
148159
SupportsHostAlias: true,
149160
},
150161
{
151162
Engine: lmstudio,
152163
StandalonePort: 1234,
153-
Routes: append([]route{
154-
{Path: "/v1/models", Role: roleModelListOpenAIGET},
155-
}, openAIInferenceRoutes...),
156-
ModelNaming: exactID,
164+
Routes: lmstudioRoutes,
165+
ModelNaming: exactID,
157166
// 1235 is where engine-manager runs a managed LM Studio, so a
158167
// proxy that restored it would sit on the engine's own port. The
159168
// stored value predates the current default of 1234.

‎services/nvpair-proxy/failover_test.go‎

Lines changed: 87 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -354,58 +354,6 @@ func TestHandleHTTP_AllNodesDownReturnsError(t *testing.T) {
354354
})
355355
}
356356

357-
// TestHandleHTTP_InferenceRouting proves every path classified as inference
358-
// applies model-based candidate filtering and is forwarded unchanged — not
359-
// only the single engineCase.inferencePath most bodies use. That includes
360-
// shared paths such as /v1/messages.
361-
func TestHandleHTTP_InferenceRouting(t *testing.T) {
362-
forEachEngine(t, func(t *testing.T, tc engineCase) {
363-
for _, r := range tc.profile.Routes {
364-
if r.Role != roleInferencePOST {
365-
continue
366-
}
367-
path := r.Path
368-
t.Run(path, func(t *testing.T) {
369-
var gotBody string
370-
var gotPath string
371-
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
372-
b, _ := io.ReadAll(r.Body)
373-
gotBody = string(b)
374-
gotPath = r.URL.Path
375-
w.WriteHeader(http.StatusOK)
376-
}))
377-
defer good.Close()
378-
379-
wrongModel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
380-
t.Error("wrong-model node should not receive request")
381-
w.WriteHeader(http.StatusOK)
382-
}))
383-
defer wrongModel.Close()
384-
385-
disc := NewDiscovery()
386-
disc.AddManual(nodeForModel(t, "wrong", wrongModel.URL, "different-model"))
387-
disc.AddManual(nodeForModel(t, "good", good.URL, tc.advertisedModel))
388-
p := testProxy(tc.profile, disc, tc.profile.FacadePort)
389-
p.soleFacade().SetSelected("wrong")
390-
391-
body := tc.inferenceBody()
392-
rec := httptest.NewRecorder()
393-
p.soleFacade().handleHTTP(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)))
394-
395-
if rec.Code != http.StatusOK {
396-
t.Fatalf("status = %d, want 200", rec.Code)
397-
}
398-
if gotBody != body {
399-
t.Errorf("node got body %q, want %q", gotBody, body)
400-
}
401-
if gotPath != path {
402-
t.Errorf("path = %q, want %q", gotPath, path)
403-
}
404-
})
405-
}
406-
})
407-
}
408-
409357
// TestHandleHTTP_404FailoverInferenceOnly: a 404 (model-not-found) on an
410358
// inference call fails over to the next advertised owner, but a 404 on a
411359
// non-inference path is returned as-is.
@@ -431,24 +379,15 @@ func TestHandleHTTP_404FailoverInferenceOnly(t *testing.T) {
431379
return p
432380
}
433381

434-
// Inference POST: 404 on first → fail over → 200, on every classified
435-
// inference path (including shared routes such as /v1/messages).
436-
for _, r := range tc.profile.Routes {
437-
if r.Role != roleInferencePOST {
438-
continue
439-
}
440-
path := r.Path
441-
t.Run(path, func(t *testing.T) {
442-
rec := httptest.NewRecorder()
443-
newProxy().soleFacade().handleHTTP(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(tc.inferenceBody())))
444-
if rec.Code != http.StatusOK {
445-
t.Fatalf("inference 404: status = %d, want 200 (should fail over)", rec.Code)
446-
}
447-
})
382+
// Inference POST: 404 on first → fail over → 200.
383+
rec := httptest.NewRecorder()
384+
newProxy().soleFacade().handleHTTP(rec, tc.inferenceRequest())
385+
if rec.Code != http.StatusOK {
386+
t.Fatalf("inference 404: status = %d, want 200 (should fail over)", rec.Code)
448387
}
449388

450389
// An ordinary non-inference GET still returns the first node's 404.
451-
rec := httptest.NewRecorder()
390+
rec = httptest.NewRecorder()
452391
newProxy().soleFacade().handleHTTP(rec, httptest.NewRequest(http.MethodGet, tc.nonInferencePath, nil))
453392
if rec.Code != http.StatusNotFound {
454393
t.Fatalf("non-inference 404: status = %d, want 404 (must NOT fail over)", rec.Code)
@@ -702,6 +641,87 @@ func TestHandleHTTP_StrictModelRouting(t *testing.T) {
702641
})
703642
}
704643

644+
// TestHandleHTTP_InferenceRouting proves each engine inference route is
645+
// model-routed, retries a model-not-found response, and
646+
// forwards the request path and body unchanged.
647+
func TestHandleHTTP_InferenceRouting(t *testing.T) {
648+
test := func(name, path string, profiles ...engineProfile) {
649+
t.Run(name, func(t *testing.T) {
650+
for _, profile := range profiles {
651+
t.Run(profile.Name, func(t *testing.T) {
652+
requestedModel := "requested-model"
653+
advertisedModel := profile.normalizeModel(requestedModel)
654+
655+
wrongModel := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
656+
t.Error("wrong-model node should not receive request")
657+
w.WriteHeader(http.StatusOK)
658+
}))
659+
defer wrongModel.Close()
660+
661+
missing := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
662+
w.WriteHeader(http.StatusNotFound)
663+
if _, err := io.WriteString(w, `{"error":"model not found"}`); err != nil {
664+
t.Errorf("write missing-model response: %v", err)
665+
}
666+
}))
667+
defer missing.Close()
668+
669+
var gotBody string
670+
var gotPath string
671+
good := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
672+
body, err := io.ReadAll(r.Body)
673+
if err != nil {
674+
t.Errorf("read forwarded request body: %v", err)
675+
w.WriteHeader(http.StatusInternalServerError)
676+
return
677+
}
678+
gotBody = string(body)
679+
gotPath = r.URL.Path
680+
w.WriteHeader(http.StatusOK)
681+
}))
682+
defer good.Close()
683+
684+
disc := NewDiscovery()
685+
disc.AddManual(nodeForModel(t, "wrong", wrongModel.URL, "different-model"))
686+
disc.AddManual(nodeForModel(t, "missing", missing.URL, advertisedModel))
687+
disc.AddManual(nodeForModel(t, "good", good.URL, advertisedModel))
688+
p := testProxy(profile, disc, profile.FacadePort)
689+
p.soleFacade().SetSelected("wrong")
690+
// Stable node ordering would send this request to "good" first and
691+
// never exercise 404 failover. Prioritize "missing" so the test
692+
// independently proves both model filtering and retry behavior.
693+
p.SetPriority([]string{"missing", "good"})
694+
695+
body := `{"model":"requested-model","max_tokens":1024,"messages":[{"role":"user","content":"Hello"}]}`
696+
rec := httptest.NewRecorder()
697+
p.soleFacade().handleHTTP(rec, httptest.NewRequest(http.MethodPost, path, strings.NewReader(body)))
698+
699+
if rec.Code != http.StatusOK {
700+
t.Fatalf("status = %d, want 200 after 404 failover", rec.Code)
701+
}
702+
if gotBody != body {
703+
t.Errorf("node got body %q, want %q", gotBody, body)
704+
}
705+
if gotPath != path {
706+
t.Errorf("path = %q, want %q", gotPath, path)
707+
}
708+
})
709+
}
710+
})
711+
}
712+
713+
ollama := ollamaCase(t).profile
714+
lmstudio := lmstudioCase(t).profile
715+
test("native Ollama generate", "/api/generate", ollama)
716+
test("native Ollama chat", "/api/chat", ollama)
717+
test("native Ollama embeddings", "/api/embeddings", ollama)
718+
test("native Ollama embed", "/api/embed", ollama)
719+
test("OpenAI chat completions", "/v1/chat/completions", ollama, lmstudio)
720+
test("OpenAI completions", "/v1/completions", ollama, lmstudio)
721+
test("OpenAI embeddings", "/v1/embeddings", ollama, lmstudio)
722+
test("Anthropic messages", "/v1/messages", ollama, lmstudio)
723+
}
724+
705725
func TestHandleHTTP_NoAdvertisedModelRejectsLocally(t *testing.T) {
706726
forEachEngine(t, func(t *testing.T, tc engineCase) {
707727
hits := 0

‎services/tests/model_routing_interop_test.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,9 @@ func TestStrictModelRoutingAcrossProcesses(t *testing.T) {
113113
requestID++
114114
}
115115
callBrokerRPC(t, stdin, msgs, requestID, tc.rpcPrefix+":node/set-priority", map[string]any{
116-
"generation": 1,
116+
// Both facades share one proxy process, so every snapshot must
117+
// advance the process-wide generation.
118+
"generation": caseIndex + 1,
117119
"nodes": []string{missingID, unknownID, owner404ID, ownerOKID},
118120
})
119121

0 commit comments

Comments
 (0)