Files
wehub-resource-sync 1b8708893a
Security Scan / tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:12:26 +08:00

140 lines
5.7 KiB
Go

package middleware
import (
"strings"
"github.com/mudler/LocalAI/core/config"
"github.com/mudler/LocalAI/core/schema"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("routerConfigFingerprint", func() {
rc := config.RouterConfig{Classifier: "score", ClassifierModel: "arch-router"}
ctx4096 := 4096
ctx8192 := 8192
// Regression: the score classifier bakes context_size into its token
// budget at build time, and the built classifier is cached by this
// fingerprint. If context_size weren't hashed, editing it and reloading
// would return a classifier carrying the stale budget.
It("changes when the classifier model's context_size changes", func() {
cfgA := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx4096}}
cfgB := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx8192}}
Expect(routerConfigFingerprint(rc, cfgA)).NotTo(Equal(routerConfigFingerprint(rc, cfgB)))
})
It("is stable for identical classifier configs", func() {
cfgA := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx4096}}
cfgB := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx4096}}
Expect(routerConfigFingerprint(rc, cfgA)).To(Equal(routerConfigFingerprint(rc, cfgB)))
})
})
var _ = Describe("routing probe extraction and trimming", func() {
Describe("OpenAIProbeFromRequest", func() {
It("keeps a short conversation intact, newline-terminated per message", func() {
req := &schema.OpenAIRequest{Messages: []schema.Message{
{Role: "user", Content: "first"},
{Role: "assistant", Content: "second"},
{Role: "user", Content: "third"},
}}
Expect(OpenAIProbeFromRequest(req).Prompt).To(Equal("first\nsecond\nthird\n"))
})
It("flattens text blocks and skips image-only messages", func() {
req := &schema.OpenAIRequest{Messages: []schema.Message{
{Role: "user", Content: []any{
map[string]any{"type": "text", "text": "describe this"},
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:..."}},
}},
{Role: "user", Content: []any{
map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:..."}},
}},
}}
// Second message contributes no text, so it neither adds a blank
// line nor a stray newline.
Expect(OpenAIProbeFromRequest(req).Prompt).To(Equal("describe this\n"))
})
It("carries the full conversation untrimmed — trimming is each classifier's job", func() {
// The middleware no longer caps the probe by a fixed rune budget;
// every turn reaches the Probe and each classifier trims to its own
// model's context (see modelTokenTrim / promptTrimmer).
block := strings.Repeat("x", 999)
msgs := make([]schema.Message, 0, 20)
msgs = append(msgs, schema.Message{Role: "user", Content: "OLDEST" + strings.Repeat("o", 994)})
for range 18 {
msgs = append(msgs, schema.Message{Role: "user", Content: block})
}
msgs = append(msgs, schema.Message{Role: "user", Content: "NEWEST" + strings.Repeat("n", 994)})
probe := OpenAIProbeFromRequest(&schema.OpenAIRequest{Messages: msgs})
Expect(probe.Prompt).To(ContainSubstring("OLDEST"), "no turn is dropped at probe-build time")
Expect(probe.Prompt).To(ContainSubstring("NEWEST"))
// Messages preserves the per-turn split the classifier trims from.
Expect(probe.Messages).To(HaveLen(20))
Expect(probe.Messages[0]).To(ContainSubstring("OLDEST"))
Expect(probe.Messages[19]).To(ContainSubstring("NEWEST"))
})
})
Describe("AnthropicProbe", func() {
It("extracts and trims the same way as the OpenAI path", func() {
req := &schema.AnthropicRequest{Messages: []schema.AnthropicMessage{
{Role: "user", Content: "alpha"},
{Role: "assistant", Content: []any{
map[string]any{"type": "text", "text": "beta"},
}},
}}
probe, ok := AnthropicProbe(req)
Expect(ok).To(BeTrue())
Expect(probe.Prompt).To(Equal("alpha\nbeta\n"))
})
It("returns ok=false for a non-Anthropic payload", func() {
_, ok := AnthropicProbe(&schema.OpenAIRequest{})
Expect(ok).To(BeFalse())
})
})
Describe("modelTokenTrim", func() {
tok := func(string) (int, error) { return 1, nil }
depsFor := func(cfg *config.ModelConfig) ClassifierDeps {
return ClassifierDeps{
ModelLookup: func(string) *config.ModelConfig { return cfg },
TokenCounter: func(string) func(string) (int, error) { return tok },
}
}
It("still trims to the backend default when context_size is unset", func() {
// Regression: with the fixed middleware rune cap gone, an unset
// context_size must NOT disable trimming — otherwise a non-trivial
// prompt overflows the default 4096 window and every score fails.
score := config.FLAG_SCORE
cfg := &config.ModelConfig{KnownUsecases: &score} // FLAG_SCORE → batch follows context
count, ceiling := modelTokenTrim("classifier", depsFor(cfg))
Expect(count).NotTo(BeNil())
Expect(ceiling).To(Equal(4096), "unset context_size falls back to the backend default, not 0")
})
It("is bounded by the batch when the batch is smaller than the context", func() {
// The probe is one decode (n_tokens <= n_batch). A model with a
// large context but a small batch can only process the batch — the
// ceiling must follow it, not the context.
ctx8k := 8192
cfg := &config.ModelConfig{LLMConfig: config.LLMConfig{ContextSize: &ctx8k}}
cfg.Batch = 512
_, ceiling := modelTokenTrim("embedder", depsFor(cfg))
Expect(ceiling).To(Equal(512), "batch is the binding single-decode limit")
})
It("disables trimming only when no tokenizer is available", func() {
count, ceiling := modelTokenTrim("x", ClassifierDeps{ModelLookup: func(string) *config.ModelConfig { return &config.ModelConfig{} }})
Expect(count).To(BeNil())
Expect(ceiling).To(Equal(0))
})
})
})