mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-18 12:13:59 -04:00
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>
271 lines
9.1 KiB
Go
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)
|
|
}
|