mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-01 20:07:18 -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>
285 lines
8.3 KiB
Go
285 lines
8.3 KiB
Go
package main
|
|
|
|
// Regression suite for the local-store gRPC backend. Exercises the
|
|
// Stores{Set,Get,Find,Delete} surface — the only public contract.
|
|
// Callers (face/voice recognition, the routing KNN classifier) reach
|
|
// this code via grpc.Backend, so testing at the wire-shaped boundary
|
|
// matches the production import shape.
|
|
|
|
import (
|
|
"math"
|
|
"math/rand/v2"
|
|
"testing"
|
|
|
|
pb "github.com/mudler/LocalAI/pkg/grpc/proto"
|
|
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("StoresSet", func() {
|
|
It("rejects empty input", func() {
|
|
Expect(NewStore().StoresSet(&pb.StoresSetOptions{})).NotTo(Succeed(), "Set with no keys should fail")
|
|
})
|
|
|
|
It("rejects key/value length mismatch", func() {
|
|
err := NewStore().StoresSet(&pb.StoresSetOptions{
|
|
Keys: wrapKeys([][]float32{{1, 0, 0}}),
|
|
Values: wrapValues([][]byte{[]byte("a"), []byte("b")}),
|
|
})
|
|
Expect(err).To(HaveOccurred(), "len(keys) != len(values) should fail")
|
|
})
|
|
|
|
It("rejects dimension mismatch on later add", func() {
|
|
s := NewStore()
|
|
mustSet(s, [][]float32{{1, 0, 0}}, [][]byte{[]byte("3d")})
|
|
err := s.StoresSet(&pb.StoresSetOptions{
|
|
Keys: wrapKeys([][]float32{{1, 0}}),
|
|
Values: wrapValues([][]byte{[]byte("2d")}),
|
|
})
|
|
Expect(err).To(HaveOccurred(), "dimension mismatch on later Set should fail")
|
|
})
|
|
|
|
It("rejects dimension mismatch within batch", func() {
|
|
err := NewStore().StoresSet(&pb.StoresSetOptions{
|
|
Keys: wrapKeys([][]float32{{1, 0, 0}, {1, 0}}),
|
|
Values: wrapValues([][]byte{[]byte("3d"), []byte("2d")}),
|
|
})
|
|
Expect(err).To(HaveOccurred(), "mixed-dimension within one batch should fail")
|
|
})
|
|
|
|
It("merges sorted and updates existing key", func() {
|
|
s := NewStore()
|
|
mustSet(s, [][]float32{{0.3, 0, 0}, {0.1, 0, 0}}, [][]byte{[]byte("c"), []byte("a")})
|
|
mustSet(s, [][]float32{{0.2, 0, 0}, {0.1, 0, 0}}, [][]byte{[]byte("b"), []byte("a-updated")})
|
|
Expect(s.keys).To(HaveLen(3))
|
|
got := singleGet(s, []float32{0.1, 0, 0})
|
|
Expect(string(got)).To(Equal("a-updated"))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("StoresGet", func() {
|
|
It("round-trips multi-key", func() {
|
|
s := NewStore()
|
|
mustSet(s,
|
|
[][]float32{{0.1, 0.2, 0.3}, {0.4, 0.5, 0.6}, {0.7, 0.8, 0.9}},
|
|
[][]byte{[]byte("a"), []byte("b"), []byte("c")},
|
|
)
|
|
res, err := s.StoresGet(&pb.StoresGetOptions{
|
|
Keys: wrapKeys([][]float32{{0.7, 0.8, 0.9}, {0.1, 0.2, 0.3}}),
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(res.Keys).To(HaveLen(2))
|
|
})
|
|
|
|
It("omits missing keys rather than erroring", func() {
|
|
s := NewStore()
|
|
mustSet(s, [][]float32{{0.1, 0, 0}}, [][]byte{[]byte("a")})
|
|
res, err := s.StoresGet(&pb.StoresGetOptions{
|
|
Keys: wrapKeys([][]float32{{0.1, 0, 0}, {0.9, 0, 0}}),
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(res.Keys).To(HaveLen(1))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("StoresDelete", func() {
|
|
It("removes and preserves sort", func() {
|
|
s := NewStore()
|
|
mustSet(s,
|
|
[][]float32{{0.1, 0, 0}, {0.2, 0, 0}, {0.3, 0, 0}, {0.4, 0, 0}},
|
|
[][]byte{[]byte("a"), []byte("b"), []byte("c"), []byte("d")},
|
|
)
|
|
Expect(s.StoresDelete(&pb.StoresDeleteOptions{
|
|
Keys: wrapKeys([][]float32{{0.2, 0, 0}, {0.4, 0, 0}}),
|
|
})).To(Succeed())
|
|
Expect(s.keys).To(HaveLen(2))
|
|
})
|
|
|
|
It("tolerates missing keys", func() {
|
|
s := NewStore()
|
|
mustSet(s, [][]float32{{0.1, 0, 0}}, [][]byte{[]byte("a")})
|
|
Expect(s.StoresDelete(&pb.StoresDeleteOptions{
|
|
Keys: wrapKeys([][]float32{{0.9, 0, 0}}),
|
|
})).To(Succeed(), "delete of missing key should succeed")
|
|
Expect(s.keys).To(HaveLen(1))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("StoresFind", func() {
|
|
It("returns normalized top-K", func() {
|
|
s := NewStore()
|
|
mustSet(s,
|
|
[][]float32{
|
|
normalizeVec([]float32{1, 0, 0}),
|
|
normalizeVec([]float32{0, 1, 0}),
|
|
normalizeVec([]float32{0, 0, 1}),
|
|
},
|
|
[][]byte{[]byte("x"), []byte("y"), []byte("z")},
|
|
)
|
|
res, err := s.StoresFind(&pb.StoresFindOptions{
|
|
Key: &pb.StoresKey{Floats: normalizeVec([]float32{0.9, 0.1, 0})},
|
|
TopK: 2,
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(res.Keys).To(HaveLen(2))
|
|
Expect(res.Similarities[0]).To(BeNumerically(">=", res.Similarities[1]), "results not sorted desc by similarity")
|
|
Expect(string(res.Values[0].Bytes)).To(Equal("x"))
|
|
})
|
|
|
|
It("falls back for non-normalized keys", func() {
|
|
s := NewStore()
|
|
mustSet(s, [][]float32{{2, 0, 0}, {0, 3, 0}}, [][]byte{[]byte("x"), []byte("y")})
|
|
Expect(s.keysAreNormalized).To(BeFalse(), "store should report non-normalized after Set with magnitude > 1")
|
|
res, err := s.StoresFind(&pb.StoresFindOptions{
|
|
Key: &pb.StoresKey{Floats: []float32{4, 0, 0}},
|
|
TopK: 1,
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(string(res.Values[0].Bytes)).To(Equal("x"))
|
|
Expect(res.Similarities[0]).To(BeNumerically(">=", float32(0.99)))
|
|
Expect(res.Similarities[0]).To(BeNumerically("<=", float32(1.01)))
|
|
})
|
|
|
|
It("rejects zero topK", func() {
|
|
s := NewStore()
|
|
mustSet(s, [][]float32{{1, 0, 0}}, [][]byte{[]byte("x")})
|
|
_, err := s.StoresFind(&pb.StoresFindOptions{
|
|
Key: &pb.StoresKey{Floats: []float32{1, 0, 0}},
|
|
TopK: 0,
|
|
})
|
|
Expect(err).To(HaveOccurred(), "Find with topK=0 should fail")
|
|
})
|
|
|
|
It("rejects dimension mismatch", func() {
|
|
s := NewStore()
|
|
mustSet(s, [][]float32{{1, 0, 0}}, [][]byte{[]byte("x")})
|
|
_, err := s.StoresFind(&pb.StoresFindOptions{
|
|
Key: &pb.StoresKey{Floats: []float32{1, 0}},
|
|
TopK: 1,
|
|
})
|
|
Expect(err).To(HaveOccurred(), "Find with mismatched dimension should fail")
|
|
})
|
|
|
|
It("returns empty result on empty store", func() {
|
|
res, err := NewStore().StoresFind(&pb.StoresFindOptions{
|
|
Key: &pb.StoresKey{Floats: []float32{1, 0, 0}},
|
|
TopK: 5,
|
|
})
|
|
Expect(err).NotTo(HaveOccurred(), "Find on empty store should succeed")
|
|
Expect(res.Keys).To(BeEmpty())
|
|
})
|
|
|
|
It("handles topK larger than store", func() {
|
|
s := NewStore()
|
|
mustSet(s,
|
|
[][]float32{normalizeVec([]float32{1, 0, 0}), normalizeVec([]float32{0, 1, 0})},
|
|
[][]byte{[]byte("x"), []byte("y")},
|
|
)
|
|
res, err := s.StoresFind(&pb.StoresFindOptions{
|
|
Key: &pb.StoresKey{Floats: normalizeVec([]float32{1, 0, 0})},
|
|
TopK: 10,
|
|
})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(res.Keys).To(HaveLen(2))
|
|
})
|
|
})
|
|
|
|
var _ = Describe("StoresLoad", func() {
|
|
It("is a no-op", func() {
|
|
Expect(NewStore().Load(&pb.ModelOptions{Model: "any-namespace"})).To(Succeed())
|
|
})
|
|
})
|
|
|
|
func BenchmarkStoresFindNormalized(b *testing.B) {
|
|
const dim = 768
|
|
for _, n := range []int{8, 32, 128, 512} {
|
|
b.Run(fmtN(n), func(b *testing.B) {
|
|
s := buildStore(b, n, dim)
|
|
query := normalizeVec(randVec(dim, 42))
|
|
req := &pb.StoresFindOptions{Key: &pb.StoresKey{Floats: query}, TopK: 1}
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
if _, err := s.StoresFind(req); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- test helpers ---
|
|
|
|
func mustSet(s *Store, keys [][]float32, values [][]byte) {
|
|
ExpectWithOffset(1, s.StoresSet(&pb.StoresSetOptions{Keys: wrapKeys(keys), Values: wrapValues(values)})).To(Succeed())
|
|
}
|
|
|
|
func singleGet(s *Store, key []float32) []byte {
|
|
res, err := s.StoresGet(&pb.StoresGetOptions{Keys: wrapKeys([][]float32{key})})
|
|
ExpectWithOffset(1, err).NotTo(HaveOccurred())
|
|
if len(res.Values) == 0 {
|
|
return nil
|
|
}
|
|
return res.Values[0].Bytes
|
|
}
|
|
|
|
func wrapKeys(in [][]float32) []*pb.StoresKey {
|
|
out := make([]*pb.StoresKey, len(in))
|
|
for i, k := range in {
|
|
out[i] = &pb.StoresKey{Floats: k}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func wrapValues(in [][]byte) []*pb.StoresValue {
|
|
out := make([]*pb.StoresValue, len(in))
|
|
for i, v := range in {
|
|
out[i] = &pb.StoresValue{Bytes: v}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func buildStore(tb testing.TB, n, dim int) *Store {
|
|
tb.Helper()
|
|
s := NewStore()
|
|
keys := make([][]float32, n)
|
|
values := make([][]byte, n)
|
|
for i := 0; i < n; i++ {
|
|
keys[i] = normalizeVec(randVec(dim, int64(i)+1))
|
|
values[i] = []byte{byte(i)}
|
|
}
|
|
if err := s.StoresSet(&pb.StoresSetOptions{Keys: wrapKeys(keys), Values: wrapValues(values)}); err != nil {
|
|
tb.Fatal(err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func randVec(dim int, seed int64) []float32 {
|
|
r := rand.New(rand.NewPCG(uint64(seed), 0xabcdef))
|
|
v := make([]float32, dim)
|
|
for i := range v {
|
|
v[i] = float32(r.NormFloat64())
|
|
}
|
|
return v
|
|
}
|
|
|
|
func normalizeVec(v []float32) []float32 {
|
|
var sum float64
|
|
for _, x := range v {
|
|
sum += float64(x) * float64(x)
|
|
}
|
|
mag := math.Sqrt(sum)
|
|
if mag == 0 {
|
|
return v
|
|
}
|
|
out := make([]float32, len(v))
|
|
for i, x := range v {
|
|
out[i] = float32(float64(x) / mag)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func fmtN(n int) string {
|
|
return map[int]string{8: "n=8", 32: "n=32", 128: "n=128", 512: "n=512"}[n]
|
|
}
|