Files
LocalAI/backend/go/cloud-proxy/passthrough_edge_test.go
Richard Palethorpe 6a80e23733 feat(middleware): Model routing, PII filtering, Cloud model proxies (#9802)
Add a routing middleware stack and a cloud-proxy backend.

* cloud-proxy: a Go gRPC backend that forwards OpenAI- and
  Anthropic-shaped chat requests to upstream providers, with an
  optional translate mode (OpenAI request -> Anthropic /v1/messages
  -> OpenAI response) and full tool-calling support.

* routing: admission control, content-aware model routing
  (embedding cache + classifier + rerank + Arch-Router score),
  PII detection/redaction (regex + NER) with streaming filter and
  OpenAI/Anthropic adapters, and a per-user/per-key billing recorder
  backed by GORM or in-memory storage.

* middleware: UsageMiddleware records usage via the billing recorder,
  plus admission, route-model, usage-stamp and trace middlewares.

* observability: BackendTrace ring buffer stores full request bodies
  (capped), MITM proxy emits structured trace events, and router
  classifier decisions surface at /api/router/decide.

* gallery: Arch-Router-1.5B (Q4_K_M and Q8_0).

* UI: cloud-proxy model-editor fields, classifier system-prompt and
  score-normalization config, and a Traces page rendering request
  bodies.

Assisted-by: claude-code:claude-opus-4-7 [Read] [Edit] [Bash]

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-05-25 09:28:27 +02:00

271 lines
9.1 KiB
Go

package main
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strconv"
"sync"
grpc "github.com/mudler/LocalAI/pkg/grpc"
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("composeURL", func() {
// Upstream URL convention: gallery configs put the canonical path
// in upstream_url, so per-request Path is ignored. A bare-host
// upstream_url accepts the per-request path.
DescribeTable("path resolution",
func(upstream, reqPath, want string) {
got, err := composeURL(upstream, reqPath)
Expect(err).NotTo(HaveOccurred())
Expect(got).To(Equal(want))
},
Entry("full path wins", "https://api.openai.com/v1/chat/completions", "/v1/something-else", "https://api.openai.com/v1/chat/completions"),
Entry("bare host accepts path", "https://api.openai.com", "/v1/chat/completions", "https://api.openai.com/v1/chat/completions"),
Entry("root slash treated as bare", "https://api.openai.com/", "/v1/chat/completions", "https://api.openai.com/v1/chat/completions"),
Entry("bare host + empty path", "https://api.openai.com", "", "https://api.openai.com"),
)
It("returns an error on invalid upstream URL", func() {
_, err := composeURL("://garbage", "")
Expect(err).To(HaveOccurred())
})
})
var _ = Describe("applyAuthHeader", func() {
It("sets x-api-key and anthropic-version for Anthropic, no Authorization", func() {
req, _ := http.NewRequest("POST", "https://example.com", nil)
applyAuthHeader(req, providerAnthropic, "ant-key")
Expect(req.Header.Get("x-api-key")).To(Equal("ant-key"))
Expect(req.Header.Get("anthropic-version")).NotTo(BeEmpty())
Expect(req.Header.Get("Authorization")).To(BeEmpty(), "Authorization must not leak on Anthropic backend")
})
It("sets Bearer Authorization for OpenAI, no x-api-key", func() {
req, _ := http.NewRequest("POST", "https://example.com", nil)
applyAuthHeader(req, providerOpenAI, "sk-key")
Expect(req.Header.Get("Authorization")).To(Equal("Bearer sk-key"))
Expect(req.Header.Get("x-api-key")).To(BeEmpty(), "x-api-key must not leak on OpenAI backend")
})
It("defaults to Bearer when provider is empty", func() {
// Passthrough mode often has provider == "" because the operator
// doesn't claim a specific upstream wire format. Most providers
// (including OpenAI-compatible ones) accept Bearer, so default to it.
req, _ := http.NewRequest("POST", "https://example.com", nil)
applyAuthHeader(req, "", "some-key")
Expect(req.Header.Get("Authorization")).To(Equal("Bearer some-key"))
})
It("preserves an existing anthropic-version header", func() {
// If the client supplied anthropic-version (rare but legitimate
// for an upstream pinned to a specific date), the proxy must not
// clobber it.
req, _ := http.NewRequest("POST", "https://example.com", nil)
req.Header.Set("anthropic-version", "2024-10-01")
applyAuthHeader(req, providerAnthropic, "k")
Expect(req.Header.Get("anthropic-version")).To(Equal("2024-10-01"))
})
})
var _ = Describe("isHopByHopHeader", func() {
DescribeTable("hop-by-hop classification",
func(header string, want bool) {
Expect(isHopByHopHeader(header)).To(Equal(want))
},
Entry("Connection is hop-by-hop", "Connection", true),
Entry("Keep-Alive is hop-by-hop", "Keep-Alive", true),
Entry("Proxy-Connection is hop-by-hop", "Proxy-Connection", true),
Entry("Transfer-Encoding is hop-by-hop", "Transfer-Encoding", true),
Entry("TE is hop-by-hop", "TE", true),
Entry("Trailer is hop-by-hop", "Trailer", true),
Entry("Upgrade is hop-by-hop", "Upgrade", true),
Entry("Host is hop-by-hop", "Host", true),
Entry("Content-Length is hop-by-hop", "Content-Length", true),
// Case-insensitive — RFC 7230 doesn't constrain header case.
Entry("lowercase connection is hop-by-hop", "connection", true),
Entry("uppercase HOST is hop-by-hop", "HOST", true),
// Non hop-by-hop — must NOT be stripped.
Entry("Authorization is end-to-end", "Authorization", false),
Entry("Content-Type is end-to-end", "Content-Type", false),
Entry("Accept is end-to-end", "Accept", false),
Entry("X-Custom is end-to-end", "X-Custom", false),
)
})
var _ = Describe("Forward", func() {
It("strips hop-by-hop and Connection headers before upstream, preserves custom headers", func() {
gotConnection := make(chan string, 1)
gotXCustom := make(chan string, 1)
gotHost := make(chan string, 1)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotConnection <- r.Header.Get("Connection")
gotXCustom <- r.Header.Get("X-Custom")
gotHost <- r.Header.Get("Host")
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
cp := NewCloudProxy()
Expect(cp.Load(&pb.ModelOptions{
Proxy: &pb.ProxyOptions{
UpstreamUrl: upstream.URL,
Mode: modePassthrough,
},
})).To(Succeed())
addr := "test://forward-hopbyhop"
grpc.Provide(addr, cp)
c := grpc.NewClient(addr, true, nil, false)
stream, err := c.Forward(context.Background())
Expect(err).NotTo(HaveOccurred())
Expect(stream.Send(&pb.ForwardRequest{
Path: "/v1/chat/completions",
Method: "POST",
Headers: []*pb.ForwardHeader{
{Name: "Connection", Value: "keep-alive"},
{Name: "Host", Value: "spoofed.example.com"},
{Name: "X-Custom", Value: "preserved"},
},
})).To(Succeed())
Expect(stream.CloseSend()).To(Succeed())
_, _ = stream.Recv()
for {
if _, err := stream.Recv(); errors.Is(err, io.EOF) || err != nil {
break
}
}
Expect(<-gotConnection).To(BeEmpty(), "Connection must not leak to upstream")
Expect(<-gotHost).NotTo(Equal("spoofed.example.com"), "Host header must not be spoofed through")
Expect(<-gotXCustom).To(Equal("preserved"), "X-Custom header must survive")
})
It("replaces caller-supplied Authorization with the configured key", func() {
// The proxy must overwrite a client-supplied Authorization header
// so a downstream caller can't smuggle stale or wrong credentials.
gotAuth := make(chan string, 1)
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth <- r.Header.Get("Authorization")
w.WriteHeader(http.StatusOK)
}))
defer upstream.Close()
GinkgoT().Setenv("CLOUD_PROXY_AUTH_REPLACE_KEY", "sk-real")
cp := NewCloudProxy()
Expect(cp.Load(&pb.ModelOptions{
Proxy: &pb.ProxyOptions{
UpstreamUrl: upstream.URL,
Mode: modePassthrough,
ApiKeyEnv: "CLOUD_PROXY_AUTH_REPLACE_KEY",
},
})).To(Succeed())
addr := "test://forward-replaces-auth"
grpc.Provide(addr, cp)
c := grpc.NewClient(addr, true, nil, false)
stream, err := c.Forward(context.Background())
Expect(err).NotTo(HaveOccurred())
Expect(stream.Send(&pb.ForwardRequest{
Path: "/v1/chat/completions",
Method: "POST",
Headers: []*pb.ForwardHeader{
// Client-supplied Authorization with the wrong scheme / key.
{Name: "Authorization", Value: "Basic Zm9vOmJhcg=="},
},
})).To(Succeed())
Expect(stream.CloseSend()).To(Succeed())
_, _ = stream.Recv()
for {
if _, err := stream.Recv(); errors.Is(err, io.EOF) || err != nil {
break
}
}
Expect(<-gotAuth).To(Equal("Bearer sk-real"), "caller-supplied Basic header must be replaced")
})
It("handles concurrent calls without interference", func() {
// CloudProxy explicitly omits base.SingleThread — independent
// Forward streams must not block each other or leak state.
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
_, _ = w.Write(body)
}))
defer upstream.Close()
cp := NewCloudProxy()
Expect(cp.Load(&pb.ModelOptions{
Proxy: &pb.ProxyOptions{
UpstreamUrl: upstream.URL,
Mode: modePassthrough,
},
})).To(Succeed())
addr := "test://forward-concurrent"
grpc.Provide(addr, cp)
c := grpc.NewClient(addr, true, nil, false)
const N = 8
var wg sync.WaitGroup
errs := make(chan error, N)
for i := 0; i < N; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
stream, err := c.Forward(context.Background())
if err != nil {
errs <- err
return
}
payload := "request-" + string(rune('A'+idx))
if err := stream.Send(&pb.ForwardRequest{
Path: "/v1/chat/completions",
Method: "POST",
BodyChunk: []byte(payload),
}); err != nil {
errs <- err
return
}
_ = stream.CloseSend()
_, _ = stream.Recv()
var body []byte
for {
r, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
errs <- err
return
}
body = append(body, r.GetBodyChunk()...)
}
if string(body) != payload {
errs <- &echoMismatch{want: payload, got: string(body)}
}
}(i)
}
wg.Wait()
close(errs)
var collected []error
for err := range errs {
collected = append(collected, err)
}
Expect(collected).To(BeEmpty(), "no concurrent Forward call should fail")
})
})
type echoMismatch struct{ want, got string }
func (e *echoMismatch) Error() string {
return "echo mismatch: want " + strconv.Quote(e.want) + " got " + strconv.Quote(e.got)
}