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

543 lines
20 KiB
Go

package middleware_test
import (
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"github.com/labstack/echo/v4"
"github.com/mudler/LocalAI/core/backend"
"github.com/mudler/LocalAI/core/config"
. "github.com/mudler/LocalAI/core/http/middleware"
"github.com/mudler/LocalAI/core/schema"
"github.com/mudler/LocalAI/core/services/routing/router"
"github.com/mudler/LocalAI/core/templates"
"github.com/mudler/LocalAI/pkg/system"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"gopkg.in/yaml.v3"
)
// The RouteModel middleware wires the score classifier into request
// rewriting. The classifier itself is covered in
// router/score_test.go — these specs pin the middleware-level
// behaviour: candidate matching against the active label set, the
// fallback path, and the depth-1 invariant.
var _ = Describe("RouteModel middleware (score classifier)", func() {
var (
modelDir string
appConfig *config.ApplicationConfig
loader *config.ModelConfigLoader
store *fakeDecisionStore
)
BeforeEach(func() {
d, err := os.MkdirTemp("", "router-test-*")
Expect(err).NotTo(HaveOccurred())
modelDir = d
appConfig = &config.ApplicationConfig{
Context: context.Background(),
SystemState: &system.SystemState{Model: system.Model{ModelsPath: modelDir}},
}
loader = config.NewModelConfigLoader(modelDir)
store = &fakeDecisionStore{}
})
AfterEach(func() {
_ = os.RemoveAll(modelDir)
})
It("routes to a candidate whose labels cover the active set", func() {
// 3 policies, 2 candidates. Small model has [casual-chat],
// bigger has [code-generation, math-reasoning, casual-chat].
// A query that activates code-generation should fall to the
// bigger candidate because it's the only one that covers it.
routerCfg := newScoreRouterModel(modelDir, "smart-router")
writeCandidate(modelDir, "small-model")
writeCandidate(modelDir, "big-model")
s := &stubScorer{labelToLogProb: map[string]float64{
"code-generation": -0.05, // dominant
"casual-chat": -3.0,
"math-reasoning": -4.0,
}}
rec, err := runRouter(loader, appConfig, store, routerCfg, openAIChat("debug my Go null pointer"), stubScorerFactory(s))
Expect(err).NotTo(HaveOccurred())
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Body.String()).To(Equal("served:big-model"))
Expect(store.records).To(HaveLen(1))
Expect(store.records[0].ServedModel).To(Equal("big-model"))
Expect(store.records[0].Label).To(ContainSubstring("code-generation"))
})
It("prefers the smaller candidate when both cover the active set", func() {
// Both candidates list casual-chat. Admins order small →
// big, so a casual-chat-only request must route to small.
routerCfg := newScoreRouterModel(modelDir, "smart-router")
writeCandidate(modelDir, "small-model")
writeCandidate(modelDir, "big-model")
s := &stubScorer{labelToLogProb: map[string]float64{
"code-generation": -5.0,
"casual-chat": -0.05, // dominant
"math-reasoning": -5.0,
}}
rec, err := runRouter(loader, appConfig, store, routerCfg, openAIChat("hi"), stubScorerFactory(s))
Expect(err).NotTo(HaveOccurred())
Expect(rec.Body.String()).To(Equal("served:small-model"))
})
It("falls back when no candidate covers the active label set", func() {
// Only the bigger candidate covers math-reasoning. We
// deliberately drop it from the candidates list so neither
// matches; expect Fallback to fire.
routerCfg := newScoreRouterModel(modelDir, "smart-router")
// Remove the second candidate so coverage gap appears.
routerCfg.Router.Candidates = routerCfg.Router.Candidates[:1]
writeCandidate(modelDir, "small-model")
writeCandidate(modelDir, "qwen3-0.6b")
s := &stubScorer{labelToLogProb: map[string]float64{
"code-generation": -5.0,
"casual-chat": -5.0,
"math-reasoning": -0.05, // dominant — but no candidate has it
}}
rec, err := runRouter(loader, appConfig, store, routerCfg, openAIChat("3 apples cost $2.40"), stubScorerFactory(s))
Expect(err).NotTo(HaveOccurred())
Expect(rec.Body.String()).To(Equal("served:qwen3-0.6b"))
})
It("rejects candidates that reference unknown labels at build time", func() {
routerCfg := newScoreRouterModel(modelDir, "smart-router")
routerCfg.Router.Candidates = append(routerCfg.Router.Candidates, config.RouterCandidate{
Model: "broken",
Labels: []string{"nonexistent-label"},
})
writeCandidate(modelDir, "small-model")
writeCandidate(modelDir, "big-model")
writeCandidate(modelDir, "broken")
writeCandidate(modelDir, "qwen3-0.6b")
s := &stubScorer{labelToLogProb: map[string]float64{
"code-generation": -0.05,
"casual-chat": -3.0,
"math-reasoning": -4.0,
}}
_, err := runRouter(loader, appConfig, store, routerCfg, openAIChat("debug something"), stubScorerFactory(s))
// Build-time config bugs (here: a candidate referencing a
// label not declared in policies) must surface to the client
// — the previous silent-fallback behaviour hid the broken
// config and left operators wondering why traces never showed
// the classifier model running.
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("unknown label"))
})
It("returns 500 when the candidate is itself a router (depth-1 invariant)", func() {
// The candidate model is itself a router. We must reject
// the dispatch — chained routers are deliberately
// disallowed.
routerCfg := newScoreRouterModel(modelDir, "smart-router")
// Bend the test setup: replace one of the candidate-model
// configs with a nested-router config.
nestedRouter := newScoreRouterModel(modelDir, "small-model")
Expect(os.WriteFile(filepath.Join(modelDir, "small-model.yaml"), []byte(toYAML(nestedRouter)), 0o644)).To(Succeed())
writeCandidate(modelDir, "big-model")
writeCandidate(modelDir, "qwen3-0.6b")
s := &stubScorer{labelToLogProb: map[string]float64{
"code-generation": -5.0,
"casual-chat": -0.05,
"math-reasoning": -5.0,
}}
_, err := runRouter(loader, appConfig, store, routerCfg, openAIChat("hi"), stubScorerFactory(s))
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("depth-1 invariant"))
})
})
// Regression coverage for the rendered routing prompt — pins the
// guarantee that the routing system prompt (route listing, JSON
// output schema) actually reaches the classifier model. The first
// implementation of the template-aware renderer routed through
// EvaluateTemplateForPrompt, which only invokes the outer Chat
// template — and the gallery's outer Chat templates are
// `{{.Input -}}<|im_start|>assistant` shape, so .SystemPrompt was
// silently dropped. The fix routes through TemplateMessages, which
// renders each role through ChatMessage and joins the result into
// .Input. These specs would fail loudly if the renderer ever
// regresses back to bypassing per-role formatting.
var _ = Describe("RouteModel rendered classifier prompt", func() {
var (
modelDir string
appConfig *config.ApplicationConfig
loader *config.ModelConfigLoader
store *fakeDecisionStore
eval *templates.Evaluator
)
BeforeEach(func() {
d, err := os.MkdirTemp("", "router-render-*")
Expect(err).NotTo(HaveOccurred())
modelDir = d
appConfig = &config.ApplicationConfig{
Context: context.Background(),
SystemState: &system.SystemState{Model: system.Model{ModelsPath: modelDir}},
}
loader = config.NewModelConfigLoader(modelDir)
store = &fakeDecisionStore{}
eval = templates.NewEvaluator(modelDir)
})
AfterEach(func() {
_ = os.RemoveAll(modelDir)
})
It("includes the routing system prompt in the rendered ChatML envelope", func() {
// Mirrors the live arch-router-1.5b.yaml: chatml-style chat +
// chat_message templates. This is the production-wired path.
writeChatMLClassifierModel(modelDir, "arch-router")
routerCfg := newScoreRouterModel(modelDir, "smart-router")
s := &stubScorer{labelToLogProb: map[string]float64{
"code-generation": -0.05,
"casual-chat": -3.0,
"math-reasoning": -4.0,
}}
_, err := runRouterWithDeps(loader, appConfig, store, routerCfg,
openAIChat("debug this null pointer"),
ClassifierDeps{
Scorer: stubScorerFactory(s),
ModelLookup: loaderLookup(loader, appConfig),
Evaluator: eval,
})
Expect(err).NotTo(HaveOccurred())
// The routing system prompt must reach the scorer. Three
// anchors: the route-listing block, one of the JSON-shaped
// route entries (escapeJSONString preserves the description),
// and the JSON output schema instruction.
Expect(s.lastPrompt).To(ContainSubstring("<routes>"),
"system prompt dropped: rendered prompt missing route-listing block. got: %q", s.lastPrompt)
Expect(s.lastPrompt).To(ContainSubstring(`{"name": "code-generation"`),
"system prompt dropped: rendered prompt missing route entries. got: %q", s.lastPrompt)
Expect(s.lastPrompt).To(ContainSubstring(`{"route": "<name>"}`),
"system prompt dropped: rendered prompt missing JSON output schema. got: %q", s.lastPrompt)
// And the per-role envelope must be present (proves we went
// through ChatMessage, not the SystemPrompt-only path).
Expect(s.lastPrompt).To(ContainSubstring("<|im_start|>system"),
"system role marker missing — ChatMessage template wasn't invoked")
Expect(s.lastPrompt).To(ContainSubstring("<|im_start|>user"),
"user role marker missing")
// User probe makes it through the per-role template. The trailing
// \n on the probe content is added by OpenAIProbeFromRequest;
// preserved through ChatMessage rendering.
Expect(s.lastPrompt).To(ContainSubstring("debug this null pointer"),
"user probe missing from rendered prompt")
// Outer Chat template must add the assistant-open marker so
// the scorer's first predicted token is the start of the
// candidate.
Expect(s.lastPrompt).To(MatchRegexp(`<\|im_start\|>assistant\s*$`),
"rendered prompt must end at assistant-open marker. got: %q", s.lastPrompt)
})
It("refuses to build the router when the classifier model has no chat_message template", func() {
// Partial template config: only the outer Chat, no per-role piece.
// The router renders the scoring prompt client-side from the
// classifier model's own template, so a missing template is a hard
// error rather than a silent fall back to a generic ChatML envelope
// the model may not have been trained on.
writePartialClassifierModel(modelDir, "arch-router")
routerCfg := newScoreRouterModel(modelDir, "smart-router")
s := &stubScorer{labelToLogProb: map[string]float64{
"code-generation": -0.05,
"casual-chat": -3.0,
"math-reasoning": -4.0,
}}
_, err := runRouterWithDeps(loader, appConfig, store, routerCfg,
openAIChat("hello world"),
ClassifierDeps{
Scorer: stubScorerFactory(s),
ModelLookup: loaderLookup(loader, appConfig),
Evaluator: eval,
})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("no chat template"),
"missing classifier template must surface as a clear config error. got: %v", err)
})
It("uses the classifier model's first stopword as the candidate suffix", func() {
writeChatMLClassifierModel(modelDir, "arch-router")
routerCfg := newScoreRouterModel(modelDir, "smart-router")
s := &stubScorer{labelToLogProb: map[string]float64{
"code-generation": -0.05,
"casual-chat": -3.0,
"math-reasoning": -4.0,
}}
_, err := runRouterWithDeps(loader, appConfig, store, routerCfg,
openAIChat("hi"),
ClassifierDeps{
Scorer: stubScorerFactory(s),
ModelLookup: loaderLookup(loader, appConfig),
Evaluator: eval,
})
Expect(err).NotTo(HaveOccurred())
// arch-router YAML lists <|im_end|> first.
for _, c := range s.lastCandidates {
Expect(c).To(HaveSuffix("<|im_end|>"),
"candidate must end with the classifier model's turn-end token. got: %q", c)
}
})
It("picks the actual turn-end token when the stopwords list is misordered (Llama-3 style)", func() {
// gallery/llama3-instruct.yaml et al. defensively list
// <|im_end|> first even though the actual Llama-3 assistant
// turn-end is <|eot_id|>. The naive "stopwords[0]" pick would
// suffix candidates with <|im_end|> — a token Llama-3 never
// emits at turn end. pickAssistantTurnEnd should scan the
// chat_message template and recognise <|eot_id|> as the real
// turn-end.
writeLlama3StyleClassifierModel(modelDir, "arch-router")
routerCfg := newScoreRouterModel(modelDir, "smart-router")
s := &stubScorer{labelToLogProb: map[string]float64{
"code-generation": -0.05,
"casual-chat": -3.0,
"math-reasoning": -4.0,
}}
_, err := runRouterWithDeps(loader, appConfig, store, routerCfg,
openAIChat("hi"),
ClassifierDeps{
Scorer: stubScorerFactory(s),
ModelLookup: loaderLookup(loader, appConfig),
Evaluator: eval,
})
Expect(err).NotTo(HaveOccurred())
for _, c := range s.lastCandidates {
Expect(c).To(HaveSuffix("<|eot_id|>"),
"candidate must end with the Llama-3 turn-end token, not the misordered first stopword. got: %q", c)
}
})
})
// --- helpers ---
// stubScorer scores each candidate label according to a fixed
// label→log-prob map; per-token length is faked at 2 tokens so length
// normalisation is a no-op. Captures the prompt + candidate list of
// the last Score call so regression tests can pin the rendered prompt
// shape.
type stubScorer struct {
labelToLogProb map[string]float64
lastPrompt string
lastCandidates []string
}
func (s *stubScorer) Score(_ context.Context, prompt string, candidates []string) ([]backend.CandidateScore, error) {
s.lastPrompt = prompt
s.lastCandidates = append([]string(nil), candidates...)
out := make([]backend.CandidateScore, len(candidates))
for i, c := range candidates {
// Match against the full `{"route": "<label>"}` envelope.
// Naively substring-matching on `"<label>"` would let a label
// that's a substring of another collide via Go's randomised
// map iteration order — `"code"` would also match the
// `"code-generation"` candidate.
var lp float64
for label, v := range s.labelToLogProb {
if strings.Contains(c, `{"route": "`+label+`"}`) {
lp = v
break
}
}
out[i] = backend.CandidateScore{
LogProb: lp * 2,
LengthNormalizedLogProb: lp,
NumTokens: 2,
}
}
return out, nil
}
func stubScorerFactory(s *stubScorer) ScorerFactory {
return func(string) backend.Scorer { return s }
}
type fakeDecisionStore struct {
records []router.DecisionRecord
}
func (f *fakeDecisionStore) Record(_ context.Context, r router.DecisionRecord) error {
f.records = append(f.records, r)
return nil
}
func (f *fakeDecisionStore) List(_ context.Context, _ router.DecisionListQuery) ([]router.DecisionRecord, error) {
out := append([]router.DecisionRecord(nil), f.records...)
return out, nil
}
func (f *fakeDecisionStore) Close() error { return nil }
func (f *fakeDecisionStore) Count(_ context.Context) (int, error) { return len(f.records), nil }
// newScoreRouterModel builds a smart-router config with 3 policies
// and 2 candidates (small with one label, bigger with all three).
// Admins are expected to order candidates small → large; the
// middleware picks the first whose labels are a superset of the
// active set.
func newScoreRouterModel(modelDir, name string) *config.ModelConfig {
cfg := &config.ModelConfig{
Name: name,
Router: config.RouterConfig{
Classifier: "score",
ClassifierModel: "arch-router",
Fallback: "qwen3-0.6b",
Policies: []config.RouterPolicy{
{Label: "code-generation", Description: "writing or debugging code"},
{Label: "casual-chat", Description: "small talk"},
{Label: "math-reasoning", Description: "arithmetic and word problems"},
},
Candidates: []config.RouterCandidate{
{Model: "small-model", Labels: []string{"casual-chat"}},
{Model: "big-model", Labels: []string{"code-generation", "casual-chat", "math-reasoning"}},
},
},
}
Expect(os.WriteFile(filepath.Join(modelDir, name+".yaml"), []byte(toYAML(cfg)), 0o644)).To(Succeed())
return cfg
}
func writeCandidate(modelDir, name string) {
body := "name: " + name + "\nbackend: mock-backend\n"
Expect(os.WriteFile(filepath.Join(modelDir, name+".yaml"), []byte(body), 0o644)).To(Succeed())
}
func toYAML(cfg *config.ModelConfig) string {
b, err := yaml.Marshal(cfg)
Expect(err).NotTo(HaveOccurred())
return string(b)
}
func openAIChat(content string) *schema.OpenAIRequest {
req := &schema.OpenAIRequest{
Messages: []schema.Message{
{Role: "user", Content: content},
},
}
req.Model = "smart-router"
return req
}
func runRouter(loader *config.ModelConfigLoader, appConfig *config.ApplicationConfig, store router.DecisionStore, routerCfg *config.ModelConfig, parsed any, scorerFactory ScorerFactory) (*httptest.ResponseRecorder, error) {
return runRouterWithDeps(loader, appConfig, store, routerCfg, parsed, ClassifierDeps{Scorer: scorerFactory})
}
// runRouterWithDeps is runRouter's general form: lets the caller pass
// a fully-populated ClassifierDeps (ModelLookup, Evaluator, ...) so
// tests can exercise the template-renderer + stop-token derivation
// paths, not just the bare-scorer fast path.
func runRouterWithDeps(loader *config.ModelConfigLoader, appConfig *config.ApplicationConfig, store router.DecisionStore, routerCfg *config.ModelConfig, parsed any, deps ClassifierDeps) (*httptest.ResponseRecorder, error) {
mw := RouteModel(loader, appConfig, store, nil, OpenAIProbe, router.SourceChat, deps)
req := httptest.NewRequest(http.MethodPost, "/v1/chat/completions", strings.NewReader("{}"))
rec := httptest.NewRecorder()
c := echo.New().NewContext(req, rec)
c.Set(CONTEXT_LOCALS_KEY_MODEL_CONFIG, routerCfg)
c.Set(CONTEXT_LOCALS_KEY_LOCALAI_REQUEST, parsed)
handler := mw(func(c echo.Context) error {
served, _ := c.Get(ContextKeyServedModel).(string)
return c.String(http.StatusOK, "served:"+served)
})
err := handler(c)
return rec, err
}
// loaderLookup mirrors application.ModelConfigLookup — bridges the
// loader to the ModelConfigLookup signature ClassifierDeps wants.
func loaderLookup(loader *config.ModelConfigLoader, appConfig *config.ApplicationConfig) ModelConfigLookup {
return func(name string) *config.ModelConfig {
cfg, err := loader.LoadModelConfigFileByNameDefaultOptions(name, appConfig)
if err != nil || cfg == nil {
return nil
}
return cfg
}
}
// writeChatMLClassifierModel writes a classifier model YAML that
// mirrors the live arch-router-1.5b.yaml shipped at
// volumes/models/arch-router-1.5b.yaml: ChatML chat + chat_message
// templates, score usecase, <|im_end|> first in stopwords.
func writeChatMLClassifierModel(modelDir, name string) {
body := `name: ` + name + `
backend: llama-cpp
known_usecases:
- score
stopwords:
- <|im_end|>
- <|endoftext|>
template:
chat: |
{{.Input -}}
<|im_start|>assistant
chat_message: |
<|im_start|>{{ .RoleName }}
{{- if .Content }}
{{ .Content }}
{{- end }}<|im_end|>
`
Expect(os.WriteFile(filepath.Join(modelDir, name+".yaml"), []byte(body), 0o644)).To(Succeed())
}
// writeLlama3StyleClassifierModel writes a classifier model mirroring
// gallery/llama3-instruct.yaml — stopwords defensively list <|im_end|>
// first even though the assistant turn-end is actually <|eot_id|>.
// Exercises pickAssistantTurnEnd's template scan: the right token is
// the one that appears in chat_message, not the one at position 0.
func writeLlama3StyleClassifierModel(modelDir, name string) {
body := `name: ` + name + `
backend: llama-cpp
known_usecases:
- score
stopwords:
- <|im_end|>
- <dummy32000>
- "<|eot_id|>"
- <|end_of_text|>
template:
chat: |
{{.Input }}
<|start_header_id|>assistant<|end_header_id|>
chat_message: |
<|start_header_id|>{{ .RoleName }}<|end_header_id|>
{{ .Content }}<|eot_id|>
`
Expect(os.WriteFile(filepath.Join(modelDir, name+".yaml"), []byte(body), 0o644)).To(Succeed())
}
// writePartialClassifierModel writes a classifier model that has the
// outer Chat template but no ChatMessage — exercises the
// newTemplateRenderer "refuse partial templating" branch, which makes
// buildClassifier reject the router with a missing-template error.
func writePartialClassifierModel(modelDir, name string) {
body := `name: ` + name + `
backend: llama-cpp
known_usecases:
- score
stopwords:
- <|im_end|>
template:
chat: |
{{.Input -}}
<|im_start|>assistant
`
Expect(os.WriteFile(filepath.Join(modelDir, name+".yaml"), []byte(body), 0o644)).To(Succeed())
}